RESTful API Design and a Unified Response Envelope
REST has been mythologized for too long. It is not "an HTTP API that sends JSON" — that is an HTTP API, not REST. The six constraints Roy Fielding laid out really answer one question: how do you let a system keep evolving as it grows? We only need to care about the few we can actually apply to business endpoints.
| Constraint | Plain meaning | What we can realistically do |
|---|---|---|
| Client–server separation | The front end shows, the back end holds data; neither depends on the other's internals | Decouple them with an API contract |
| Statelessness | Every request carries all it needs; the server keeps no per-client context | Authenticate with tokens, not server sessions |
| Cacheability | Responses must declare whether they may be cached | Use Cache-Control and ETag correctly |
| Uniform interface | Uniform resource identifiers, representations, self-descriptive messages, hypermedia | Noun-based URLs + standard methods + standard status codes |
| Layered system | Intermediaries such as gateways, proxies and load balancers are allowed | Centralize auth and rate limiting at a gateway |
| Code on demand (optional) | The server may ship executable code | Rarely used; ignore it |
only "statelessness" and "uniform interface" carry real value in practice. The first decides whether you can scale horizontally, the second whether a stranger can guess how to call your API without reading docs. The other four are architectural ideals; business projects need about seventy percent of them.
A URL is a resource's name, not a function-call command. The gap between the two is one or two words on paper, but "understand it at a glance" versus "dig through the docs" in practice.
| Good | Bad | Why |
|---|---|---|
GET /users/42 | GET /getUserById?id=42 | Resources are nouns; the action lives in the method |
GET /users | GET /userList | Collections are plural and consistent |
POST /users | POST /createUser | Create a new member of the collection |
PUT /users/42 | POST /updateUser | Updating is a method semantic; keep it out of the path |
DELETE /users/42 | GET /deleteUser?id=42 | Deleting must never use GET (prefetch, caches and crawlers will trigger it) |
GET /users/42/orders | GET /getOrdersByUser?uid=42 | Express ownership through hierarchy |
GET /users?status=ACTIVE&page=1 | GET /activeUsersPage1 | Filtering, paging and sorting belong in the query string |
Three iron rules: use plural nouns, express ownership through hierarchy, push filtering/paging/sorting into the query string. A verb in the path (get / create / update / delete) means you still think in procedures, not resources.
exposing a destructive action as GET /users/42/delete is the most dangerous variant. GET is a "safe method" that browsers, proxies and crawlers all assume may be repeated freely — prefetching will delete data, and that is a real incident, not a hypothetical.
The rules above mention a "uniform prefix", and in code there are two ways to get it: hard-code /api/v1 into every @RequestMapping, or delegate it to server.servlet.context-path. Choose wrong and gateway routing plus health checks trip over it together. Tick the handful of properties a public API actually needs — server for prefix and port, logging for access logs and slow requests, actuator for probe endpoints, profile so dev and prod can use different prefixes:
server:
port: 8080
servlet:
encoding: { charset: UTF-8, enabled: true, force: true }
compression: { enabled: true, min-response-size: 2048 }
spring:
application:
name: demo-service
management:
server:
port: 9090 # 管理端口与业务端口隔离
endpoints:
web:
exposure:
include: health,info,metrics,prometheus # 白名单,绝不写 *
endpoint:
health:
show-details: when_authorized
group:
liveness: { include: ping }
readiness: { include: db,redis,diskSpace }
Methods are not decoration; they carry semantic contracts:
| Method | Semantics | Idempotent | Safe | Typical status |
|---|---|---|---|---|
| GET | Read a resource | Yes | Yes | 200 / 404 |
| POST | Create a resource / trigger processing | No | No | 201 / 400 |
| PUT | Replace a resource entirely | Yes | No | 200 / 204 |
| PATCH | Partially update a resource | Usually implementation-dependent | No | 200 / 204 |
| DELETE | Remove a resource | Yes | No | 204 / 404 |
Idempotency means "the same request executed once or N times leaves the resource in the same state". This property directly determines your retry strategy: GET / PUT / DELETE are safe to retry, POST is not — a retry may place one extra order or charge a payment twice.

"Everything is 200 and the error code hides in the body" is a legacy weight many teams carry. It lets the front end check a single field, but it blinds monitoring, gateways, caches and logs.
There are not many status codes worth memorizing, but the closely related pairs are exactly the ones people misuse — 401 versus 403, 404 versus 409, 400 versus 422. Reading a table three times teaches less than one round of this: click a code, then the situation it belongs to, and a wrong pair explains the gap immediately.

Compare the two camps:
- "Everything is 200":
{"code":50001,"message":"user not found","data":null}. Easy on the front end, but a CDN will cache the 200 error response, a gateway cannot count error rates by status, and every dashboard looks green - Proper status codes: 404 plus
{"code":50001,"message":"user not found"}. Infrastructure understands it, andcodestill preserves business granularity
- Purist status-code camp: 200 with the bare payload on success, 4xx/5xx with problem details on failure. The most "standard", but unfriendly to front ends — the decision point is split across the status line and the error shape
- Envelope camp: wrap everything in
{ code, message, data }. Centralized handling for the front end, finer business errors — at the cost of drifting from the pure-REST "representation is the resource" ideal
My recommendation is to combine them: the HTTP status expresses "how the request was handled", while the envelope's code expresses "how the business logic went". Each has its job; neither replaces the other.
package com.example.common.api;import com.fasterxml.jackson.annotation.JsonInclude;import lombok.Getter;import java.io.Serializable;/** * Unified response envelope. * code is decoupled from the HTTP status: HTTP covers transport/handling, code covers business. */@Getter@JsonInclude(JsonInclude.Include.NON_NULL)public class Result<T> implements Serializable { /** Business code: 0 means success, otherwise see the ErrorCode enum */ private final int code; private final String message; private final T data; private Result(int code, String message, T data) { this.code = code; this.message = message; this.data = data; } public static <T> Result<T> ok(T data) { return new Result<>(0, "success", data); } public static <T> Result<T> ok() { return new Result<>(0, "success", null); } public static <T> Result<T> fail(int code, String message) { return new Result<>(code, message, null); } public static <T> Result<T> fail(ErrorCode errorCode) { return new Result<>(errorCode.getCode(), errorCode.getMessage(), null); }}Hand-writing Result.ok(...) has a flaw: someone always forgets to wrap, and the API shape becomes inconsistent. ResponseBodyAdvice can wrap automatically before serialization, while excluding special returns such as file downloads.
package com.example.common.api;import org.springframework.core.MethodParameter;import org.springframework.http.MediaType;import org.springframework.http.converter.HttpMessageConverter;import org.springframework.http.server.ServerHttpRequest;import org.springframework.http.server.ServerHttpResponse;import org.springframework.web.bind.annotation.RestControllerAdvice;import org.springframework.web.servlet.mvc.method.annotation.ResponseBodyAdvice;/** * Automatically wraps controller return values in Result. * Only touches methods annotated @WrapResult, so downloads and third-party callbacks stay intact. */@RestControllerAdvicepublic class ResultWrapAdvice implements ResponseBodyAdvice<Object> { @Override public boolean supports(MethodParameter returnType, Class<? extends HttpMessageConverter<?>> converterType) { // Already a Result: skip. Annotated @SkipWrap: always skip. return returnType.getMethodAnnotation(SkipWrap.class) == null && !Result.class.isAssignableFrom(returnType.getParameterType()); } @Override public Object beforeBodyWrite(Object body, MethodParameter returnType, MediaType selectedContentType, Class<? extends HttpMessageConverter<?>> converterType, ServerHttpRequest request, ServerHttpResponse response) { // Strings need special care or they clash with StringHttpMessageConverter if (body instanceof String) { return body; // let the caller return a Result itself } return Result.ok(body); }}Pair it with a marker annotation:
@Target(ElementType.METHOD)@Retention(RetentionPolicy.RUNTIME)public @interface SkipWrap {}Warning: once global wrapping is on, every String return hits a snag — Spring handles strings with StringHttpMessageConverter, and wrapping a string in Result then serializing it collides with it, producing anything from a type-conversion exception to escaped JSON garbage. Either special-case String in beforeBodyWrite, or simply have handlers return objects instead of strings.
Every node on this chain has a named owner, and clicking through it beats reading the code. Node ⑤ is the crime scene of the warning above:
That String fork can be clicked in the kernel. One return, one annotation swapped, and the exit changes — and the missing scene is the whitelabel 404 from the error table:
Exceptions are not an appendix to logs; they are the carrier of business error codes. A clean system has two layers: an error-code enum and a business exception.
package com.example.common.exception;import lombok.Getter;@Getterpublic enum ErrorCode { SUCCESS(0, "success"), INVALID_PARAM(40000, "invalid parameter"), USER_NOT_FOUND(40401, "user not found"), USERNAME_TAKEN(40901, "username already taken"), ORDER_STATE_CONFLICT(40902, "order state does not allow this operation"), PERMISSION_DENIED(40300, "no permission to perform this operation"), UNAUTHORIZED(40100, "session expired, please sign in again"), SYSTEM_ERROR(50000, "the service is busy, please retry later"); private final int code; private final String message; ErrorCode(int code, String message) { this.code = code; this.message = message; }}package com.example.common.exception;import lombok.Getter;/** A business exception: safe to show the user; never leak a 5xx stack trace */@Getterpublic class BizException extends RuntimeException { private final ErrorCode errorCode; public BizException(ErrorCode errorCode) { super(errorCode.getMessage()); this.errorCode = errorCode; } public BizException(ErrorCode errorCode, String message) { super(message); this.errorCode = errorCode; } // Skip the stack: business exceptions do not need it, saving considerable overhead @Override public synchronized Throwable fillInStackTrace() { return this; }}The mapping between business exceptions and HTTP status codes is the glue of the whole system:
| Business exception | Business code | HTTP status |
|---|---|---|
BizException(INVALID_PARAM) | 40000 | 400 |
BizException(UNAUTHORIZED) | 40100 | 401 |
BizException(PERMISSION_DENIED) | 40300 | 403 |
BizException(USER_NOT_FOUND) | 40401 | 404 |
BizException(USERNAME_TAKEN) | 40901 | 409 |
BizException(ORDER_STATE_CONFLICT) | 40902 | 409 / 422 |
An uncaught Exception | 50000 | 500 |
That mapping table is the glue of the whole system: one business failure travels seven steps from throw to landing, and steps ④ and ⑤ decide respectively what code sits in the body and what the status line says. The animation's two readers — the gateway and the page — each read only their own field, and neither can substitute for the other:

the overridden fillInStackTrace() deserves a word of its own. A business exception is control flow, not an accident: a stack for "username already taken" proves only that you constructed it, and collecting frames on a hot endpoint costs real CPU. Drop it, and locate problems in the logs by code plus the key business identifier — that is what treating exceptions as return values properly looks like.
Once an API is public, you must plan for "old clients are still calling it". The three mainstream strategies each trade off differently:
| Strategy | Form | Pros | Cons |
|---|---|---|---|
| URL version | /api/v1/users | Obvious, easy to debug, easy to route at a gateway | The URL changes; not strictly "pure" |
| Header version | X-API-Version: 1 | The URL stays stable | Hard to debug; invisible in the address bar |
| Media-type version | Accept: application/vnd.demo.v1+json | The most "RESTful" | Verbose, weak tooling support |
the vast majority of teams should pick URL versioning. It is obvious, shareable, openable directly in a browser, and a gateway can route by path prefix. Media-type versioning is theoretically ideal and practically the worst — unless you maintain a public API that must adhere to REST to the letter.
Pagination parameters need a single convention, or every endpoint invents its own and the front end suffers. Recommended:
- Request:
?page=1&size=20&sort=createdAt,desc - Response: return the total plus the current page
package com.example.common.api;import java.util.List;/** A page result: total lets the client compute page counts, list is the current page */public record PageResult<T>(long total, int page, int size, List<T> list) { public static <T> PageResult<T> of(long total, int page, int size, List<T> list) { return new PageResult<>(total, page, size, list); }}@GetMapping("/users")public PageResult<UserVO> page( @RequestParam(defaultValue = "1") int page, @RequestParam(defaultValue = "20") int size, @RequestParam(defaultValue = "createdAt,desc") String sort) { return userService.page(page, size, sort);}Fix the names as page / size / sort, with sort in field,direction form, consistent across the whole site — then one pagination component on the front end serves every list.
Naming conventions tame the parameter names; they say nothing about the parameter values. How large size may get is a genuine capacity line, held by spring.data.web.pageable.max-page-size — and when it is crossed the consequence is a silent truncation, not an error. Drag the number and watch where the contract starts to deform:
- Boot's default ceiling for Pageable is exactly 2000, and above it the request is truncated rather than rejected
- A caller asking for 5000 gets 2000 and only discovers it by checking total against the list length
- Two thousand entities plus the ORM first-level cache can easily occupy tens of megabytes of heap per request
- Fine for internal systems; a public endpoint should usually sit a notch lower — do not publish a default as a contract
- Mixing up PUT and PATCH: PUT replaces the whole resource (omitted fields are cleared), PATCH updates partially. Pick one and write it into the standard, or "update user" clears or preserves fields depending on the developer's mood
- DELETE returning 200 or 204: both are valid. 204 is more semantically pure (no content); 200 is easier for a front end to parse uniformly. The point is consistency — do not have one endpoint return 204 and another 200
- Paging starting at 0 or 1: Spring Data's
Pageablestarts at 0 by default while most front-end components start at 1. This is purely a "team convention" with no right answer, but it must be pinned down in the project standard
The first ten sections are clauses. This one compresses them into a single picture.

REST boils down to one sentence — whom you are calling, and what you want to do. To visit Mr Zhang the correct phrasing is "Happy Road no. 42" (the house number = the URL) plus "visit" (the action = the HTTP method), not inventing a new place called "visit-Mr-Zhang-at-Happy-Road-42". The payoff: every action on "Happy Road no. 42" shares one address (GET/PUT/DELETE /users/42), so the courier (gateway), the guard (cache) and the property office (monitoring) can all decide from address plus action whether to let it through, retry it, or count it as a failure. With invented place names, every new action needs a new street and nobody can navigate without your map (docs). The URL says what is operated, the method says how, the status code says how it went — that is the whole idea.

a unified response envelope is like the same letterhead printed on every hospital slip. Lab report, receipt, discharge summary — the top-right corner always carries the same three fields: department code (code), a one-line note (message), and the actual content (data). Once the reception desk (the frontend) recognises that letterhead, one piece of code handles every document. Meanwhile the stamp on the envelope (the HTTP status code) is for the postman (gateway, CDN, monitoring) — two audiences, so neither may be dropped. That is the real reason Section 5 recommends the hybrid camp.
After this article you should answer:
- "What is wrong with
POST /createUser?" (A verb leaked into the house number; creating into a collection should bePOST /users) - "Why must deletion never use GET?" (GET is assumed safe and repeatable — prefetchers and crawlers will delete for you)
- "What is the relationship between status 404 and a body
codeof 40401?" (One speaks to infrastructure, the other to business branches)
REST is not memorized, it is looked at: one glance at the request, one at the response. The four demos below are four cross-sections — click them in order.
Cross-section one: content negotiation — why the same code returns JSON today and a 406 tomorrow.
Linger on the fail scene: it produces HttpMediaTypeNotAcceptableException, the first row of Section 13's table. Note that a 406 is caused by the client's Accept, not by your return value being wrong — many beginners immediately go edit the controller, which is exactly backwards.
Cross-section two: how an error becomes a proper error response.
The none scene is the shape the "everything is 200" camp fears most: nobody catches it → 500 plus the Whitelabel page, the gateway's error rate suddenly doubles, when all you wanted to say was "username already taken".
Cross-section three: how a resource-style URL actually gets matched.
Putting /users/42 (one resource) beside /users (the collection) on a single chain makes Section 2's iron rule concrete: hierarchy expresses ownership, the query string expresses filtering.
Cross-section four: the whole API pipeline — including failures and slowness.
The three above are local views. This one threads filters, dispatch, validation, the service layer and the database together; its five scenes are precisely this article's five sections bound into one:
The last animation shows exactly at which step the Sections 5–6 envelope gets put on — once you see that, the String warning in Section 6 stops being a rule to memorize:

With the four cross-sections clicked through, switch to a command line. This console is wired to the same kernel inside your browser and every reply is computed — run boot, then type the five endings of this chapter with lab:
lab conv fail and lab err handler are both "the endpoint answered with JSON and something was wrong", but the first is a 406 caused by the caller's Accept, while the second is a 4xx produced by your own @RestControllerAdvice. Section 4's rule — status codes for infrastructure, code for business branches — reads most clearly in the difference between these two outputs.
| Error snippet | Real cause | Thirty-second fix | Dig deeper in |
|---|---|---|---|
406 Not Acceptable plus org.springframework.web.HttpMediaTypeNotAcceptableException: No acceptable representation | No converter can produce a type the client's Accept allows (e.g. it accepts only application/xml but Jackson XML is not on the classpath) | Prove the endpoint itself is alive with curl -H "Accept: application/json"; then confirm the caller's header is too picky | #22 Section 7 |
415 Unsupported Media Type plus HttpMediaTypeNotSupportedException: Content-Type 'application/x-www-form-urlencoded;charset=UTF-8' is not supported | Nobody can read the media type @RequestBody received | Send -H "Content-Type: application/json", or drop @RequestBody and bind with @ModelAttribute | #23 Section 6 |
JSON parse error: Cannot deserialize value of type java.util.Date from String "2024-05-01 10:00:00" | Jackson accepts ISO-8601 (2024-05-01T10:00:00.000+08:00) or a timestamp by default | Put @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") on the field, or set spring.jackson.date-format globally | End of this section |
InvalidDefinitionException: Java 8 date/time type ... not supported by default when serializing LocalDateTime | The jackson-datatype-jsr310 module is not registered | spring-boot-starter-web already ships it; you only miss it after hand-constructing an ObjectMapper | End of this section |
Unrecognized field "user_name" (class UserCreateDTO), not marked as ignorable (3 known properties: ...) | Names differ between client and DTO while strict mode is switched on | Align the naming or add @JsonProperty("user_name"); relaxation is discussed below | #23 Section 11 |
Required request body is missing (HttpMessageNotReadableException) | @RequestBody declared but no body arrived; also typical when an interceptor/filter already consumed the stream | Wrap the request in a ContentCachingRequestWrapper so the body stays re-readable | #23 Section 8 |
After adding a ResponseBodyAdvice, endpoints returning String throw ClassCastException: Result cannot be cast to java.lang.String | The wrapped object reaches StringHttpMessageConverter, which only eats strings | Special-case body instanceof String in beforeBodyWrite, or have that endpoint return an object | Section 6 |
| A download endpoint receives escaped JSON instead of a file | Global wrapping swallowed a binary/text response | Add a marker such as @SkipWrap and exclude it inside supports | Section 6 |
NoResourceFoundException / static resources 404 while APIs work | Resource-handler behaviour changed across versions (Boot 3.2+ no longer hands unknown paths to static handling by default) | Set spring.web.resources.add-mappings=true explicitly and re-check the paths | #22 Section 15 |
CORS reports Response to preflight request doesn't pass access control check | The OPTIONS preflight was blocked by a login interceptor | Handle CORS with CorsFilter / addCorsMappings and let OPTIONS through; never manage CORS in an MVC interceptor | #26 |
The 406 in the first row is the one beginners investigate in the wrong place: it looks like a server fault, yet it is the caller's request header. Here is that stack for real — do not read the analysis first; click the frame you think is guilty:
During integration the front end reports 406 on every call. You paste the same URL into the browser and it returns data normally, so for a while you suspect something in the middle is rewriting the response.
once Jackson's date format is relaxed globally it becomes an implicit contract. The recommended move is to write the format into the DTO: @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8"), or standardise the API layer on ISO-8601 strings (which new Date() parses directly). spring.jackson.serialization.write-dates-as-timestamps=false is the laziest switch but affects only JSR-310 types; legacy java.util.Date still follows spring.jackson.date-format.
Unified envelope, status codes, pagination and versioning each carry a trade-off. Pick a combination and the output tells you what that contract looks like under real operations.
GET /api/v1/users?page=1&size=20 -> 200{"code":0,"data":{"total":137,"list":[...]}}gateway error-rate panel: truthful#the sane default for most internal and small public APIs
Goal: assemble the minimum trio — resource-style API + unified envelope + global exception — and verify four status codes with curl.
package com.example.lab.rest;import org.springframework.http.HttpStatus;import org.springframework.http.ResponseEntity;import org.springframework.web.bind.annotation.*;import java.time.LocalDate;import java.util.List;import java.util.Map;import java.util.concurrent.ConcurrentHashMap;import java.util.concurrent.atomic.AtomicLong;@RestController@RequestMapping("/api/v1/books") // resource URL + URL versionpublic class BookLabController { public record Book(Long id, String title, LocalDate published) {} public record CreateForm(@jakarta.validation.constraints.NotBlank String title, LocalDate published) {} public record PageResult<T>(long total, int page, int size, List<T> list) {} private static final Map<Long, Book> STORE = new ConcurrentHashMap<>(); private static final AtomicLong SEQ = new AtomicLong(); static { // a little seed data SEQ.set(2); STORE.put(1L, new Book(1L, "Domain-Driven Design", LocalDate.of(2003, 8, 20))); STORE.put(2L, new Book(2L, "Spring in Action", LocalDate.of(2020, 5, 1))); } // collection: paging and filtering all ride on the query string -> 200 @GetMapping public Map<String, Object> list(@RequestParam(defaultValue = "1") int page, @RequestParam(defaultValue = "20") int size) { var list = STORE.values().stream().sorted((a, b) -> a.id().compareTo(b.id())) .skip((long) (page - 1) * size).limit(size).toList(); return Map.of("code", 0, "message", "success", "data", new PageResult<>(STORE.size(), page, size, list)); } // one resource: present -> 200, absent -> a real 404 (never 200 + code) @GetMapping("/{id}") public ResponseEntity<Map<String, Object>> get(@PathVariable Long id) { Book book = STORE.get(id); if (book == null) { return ResponseEntity.status(HttpStatus.NOT_FOUND) .body(Map.of("code", 40401, "message", "book not found")); } return ResponseEntity.ok(Map.of("code", 0, "message", "success", "data", book)); } // creation -> 201 + Location @PostMapping public ResponseEntity<Map<String, Object>> create(@jakarta.validation.Valid @RequestBody CreateForm form) { Long id = SEQ.incrementAndGet(); Book saved = new Book(id, form.title(), form.published() == null ? LocalDate.now() : form.published()); STORE.put(id, saved); return ResponseEntity.created(java.net.URI.create("/api/v1/books/" + id)) .body(Map.of("code", 0, "message", "success", "data", saved)); } // deletion -> 204, no body @DeleteMapping("/{id}") public ResponseEntity<Void> delete(@PathVariable Long id) { return STORE.remove(id) == null ? ResponseEntity.status(HttpStatus.NOT_FOUND).build() : ResponseEntity.noContent().build(); }}Five curls and their expected answers:
# 1) collection -> 200curl -i "http://localhost:8080/api/v1/books?page=1&size=20"# 2) single resource -> 200curl -i http://localhost:8080/api/v1/books/1# 3) missing resource -> 404 (the point: the status really is 404)curl -i http://localhost:8080/api/v1/books/999# 4) creation -> 201 plus a Location headercurl -i -X POST http://localhost:8080/api/v1/books \ -H "Content-Type: application/json" -d '{"title":"Refactoring","published":"2018-11-19"}'# 5) required field missing -> 400 (@Valid raises MethodArgumentNotValidException)curl -i -X POST http://localhost:8080/api/v1/books \ -H "Content-Type: application/json" -d '{"title":""}'Expected response for command 2:
{ "code": 0, "message": "success", "data": { "id": 1, "title": "Domain-Driven Design", "published": "2003-08-20" }}For command 3 expect the status line HTTP/1.1 404 with body {"code":40401,"message":"book not found"}. For command 4 expect HTTP/1.1 201 and a Location: /api/v1/books/3 header. For command 5 expect HTTP/1.1 400 — if you get a 200 there, you forgot @Valid before the fourth parameter.
- Change command 3's status to
HttpStatus.OK, leaving the body untouched → you observecurl -ireporting 200, and a follow-upcurl -sI .../books/999revealing nothing. That is Section 14's second question: you can now reproduce "all dashboards green" with one command - Return a bare
PageResult<Book>fromlist(PageResultis defined in Section 9) and add aResponseBodyAdvicethat wraps automatically → you observe an identical response shape while the controller no longer contains anyMap.of("code", 0, ...). Then add@GetMapping("/ping") public String ping()→ you observe aClassCastExceptionor a lump of escaped JSON, reproducing Section 6's warning exactly - Swap
Book.publishedfromLocalDatetojava.util.Dateand rerun command 1 → you observe millisecond timestamps (e.g."published": 1061308800000). Add@JsonFormat(pattern = "yyyy-MM-dd", timezone = "GMT+8")and rerun → you observe"2003-08-20"again. Both are legal, but they must be uniform site-wide or the frontend ends up with two parsers - Drop the
/api/v1prefix and match on a header instead (@RequestMapping(value="/books", headers="X-API-Version=1")) → you observe that openinglocalhost:8080/booksin a browser instantly gives a 404, because an address bar cannot send headers. That is the concrete shape of Section 8's "worst practical experience"
Build a publishable "book API" that turns every rule in this article into a deliverable contract.
Requirements:
- Resource URLs:
/api/v1/books,/api/v1/books/{id},/api/v1/books/{id}/reviews(hierarchy for ownership) - Full method semantics:
GET(200),POST(201 + Location),PUT(full replacement, 200),PATCH(partial update, 200),DELETE(204) - Unified
Result<T>+ErrorCode+BizException+@RestControllerAdvice, with business codes mapped to HTTP statuses per Section 7's table - Fixed pagination parameters
page/size/sortreturningtotal;sizeabove 100 yields a 400 rather than a slow query - Idempotency safety: calling
PUT/DELETEtwice on the same id must be predictable the second time (200/204 or 404) — never a 500
Acceptance checklist:
- [ ]
curl -i -X DELETE .../books/1twice: first 204, second 404, neither a 500 - [ ]
curl -i -X POST .../books -H "Content-Type: application/json" -d '{"title":"x"}'returns 201 with aLocationheader - [ ] Trigger a
BizException(USERNAME_TAKEN)-style conflict: expect 409 with acodein the body - [ ]
curl -i -H "Accept: application/xml" .../booksreturns 406 and the log namesHttpMediaTypeNotAcceptableException - [ ] Every list response has an identical
datashape (onePageResult), so the frontend writes the pager once - [ ] You can state in one sentence why the status code and
codemust not be merged into one
without looking up, can you give each of GET/POST/PUT/PATCH/DELETE's idempotency and typical status codes? (GET safe and idempotent 200/404; POST non-idempotent 201/400; PUT idempotent 200/204; PATCH implementation-dependent; DELETE idempotent 204/404)
who is responsible for 404, 406 and 415 respectively? (404 = no mapping matched, or the resource is absent; 406 = the client's Accept is too picky; 415 = nobody can read the client's Content-Type)
why must Result<T>'s code and the HTTP status coexist? (Different audiences: the status serves gateway/CDN/monitoring, the code serves business branching)
why must global wrapping special-case String returns? (StringHttpMessageConverter only accepts strings, so handing it a Result collides)
the house number says WHO, the method says WHAT YOU DO, the status says WHETHER IT WORKED, the code says WHY; verbs never enter the URL.
RESTful is not mysticism; it asks you to get three things right — nouns in the URL express resources, methods express operations, status codes express results. On top of that, a unified Result<T> envelope, an ErrorCode + BizException system and a versioning strategy form an enterprise-ready API skeleton that can evolve for a long time.