What is REST — and What Problem Does It Actually Solve?
REST (Representational State Transfer) is an architectural style for distributed hypermedia systems, defined by Roy Fielding in his 2000 dissertation. It is not a protocol, a standard, or a library. It is a set of six constraints that, when all applied together, produce systems with specific properties: scalability, independent evolution of client and server, and uniform discoverability of behavior.
The practical problem REST solved was the state of web services circa 2000: SOAP APIs required bespoke XML schemas, WSDL files, specialized client tooling, and encoded every operation as a POST body — the HTTP method, status code, and URL were all ignored as semantics. REST said: HTTP already has verbs, status codes, caching, and redirection — use them instead of reinventing them inside the payload.
/*
* BEFORE REST — SOAP-style: everything is a POST, HTTP is just a transport pipe.
* The actual operation lives inside the XML envelope.
*/
POST /OrderService HTTP/1.1
Content-Type: text/xml
<soap:Envelope>
<soap:Body>
<GetOrderRequest>
<orderId>9001</orderId>
</GetOrderRequest>
</soap:Body>
</soap:Envelope>
// Status code is always 200 — even errors return 200 with a fault envelope.
// Caching is impossible: every call is a POST.
// Any client needs the WSDL to understand what to send.
/*
* AFTER REST — HTTP semantics do the work; the payload carries only data.
*/
GET /api/v1/orders/9001 HTTP/1.1
Accept: application/json
HTTP/1.1 200 OK
Cache-Control: max-age=60
Content-Type: application/json
{"id": 9001, "status": "SHIPPED", "total": 129.90}
// Any HTTP client understands GET is safe to cache and retry.
// 200 means success. 404 means not found. No schema needed.
The Six REST Constraints
An API is RESTful when it satisfies all six constraints. "REST-like" or "REST-ish" means some are missing — usually statelessness or uniform interface. That's not necessarily wrong, but it means you can't claim the architectural properties REST provides.
1. Client-Server Separation
Client and server are independent: the server doesn't know or care how the client renders data; the client doesn't know or care how the server stores it. They communicate only through the agreed interface. This is why your Spring Boot API can serve a browser SPA, an iOS app, and a CLI tool simultaneously without changing a line of server code.
2. Statelessness
Every request must contain all information needed to process it. The server holds no per-client session state between requests.
// WRONG — server remembers which page the client is on
@GetMapping("/api/orders/next")
public List<OrderResponse> getNextPage(HttpSession session) {
int page = (int) session.getAttribute("currentPage"); // server state
return orderService.findPage(page + 1);
// Any server instance that doesn't have this session will fail.
// Horizontal scaling requires sticky sessions or session replication.
}
// CORRECT — client sends everything needed
@GetMapping("/api/orders")
public Page<OrderResponse> getOrders(
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size,
@RequestHeader("Authorization") String bearer) {
// All context in the request: identity (token), page, size.
// Any instance can handle this.
return orderService.findPage(page, size);
}
3. Cacheability
Responses must declare themselves as cacheable or not, so clients and intermediaries (CDNs, proxies) can serve repeat requests without touching the origin server. GET responses to read-only data are the most obvious candidates.
@GetMapping("/api/products/{id}")
public ResponseEntity<ProductResponse> getProduct(@PathVariable Long id) {
ProductResponse product = productService.findById(id);
return ResponseEntity.ok()
.cacheControl(CacheControl.maxAge(1, TimeUnit.HOURS))
.eTag(String.valueOf(product.version())) // client sends If-None-Match next time → 304 if unchanged
.body(product);
}
@GetMapping("/api/stock/{productId}")
public ResponseEntity<StockResponse> getStock(@PathVariable Long productId) {
return ResponseEntity.ok()
.cacheControl(CacheControl.noCache()) // stock changes too fast to cache
.body(stockService.findByProduct(productId));
}
4. Uniform Interface
The most distinctive REST constraint. Four sub-constraints: resource identification via URIs; manipulation through representations; self-descriptive messages (the request contains everything needed to process it); and HATEOAS (responses contain links to related state transitions). In practice, most APIs implement the first three and skip HATEOAS — making them REST-like but not strictly RESTful per Fielding. Whether you need full HATEOAS is covered in Section 5 below.
5. Layered System
The client can't tell whether it's talking directly to the origin
server or through a CDN, an API gateway, or a load balancer. This is
what makes it safe for a CDN to cache GET /api/products/123
without the server needing to know or agree with it explicitly — the
caching contract is in the HTTP headers, not in a bilateral
agreement.
6. Code on Demand (Optional)
The only optional constraint. The server can extend the client by sending executable code — think JavaScript served with HTML pages. In pure REST APIs this is rarely relevant and almost never used, due to the security implications of executing code from an arbitrary server.
URL Design: Resources, Nouns, and the Decisions That Actually Matter
The Basic Rule: URIs Identify Resources, Methods Are the Verbs
// Good: nouns, plural, lowercase, hyphens for readability
GET /api/v1/orders // collection
GET /api/v1/orders/9001 // single resource
GET /api/v1/orders/9001/items // sub-resource
POST /api/v1/orders // create
PUT /api/v1/orders/9001 // full replace
PATCH /api/v1/orders/9001 // partial update
DELETE /api/v1/orders/9001 // delete
// Bad: verbs in URLs — the HTTP method is already the verb
GET /api/getOrders // ✗
POST /api/createOrder // ✗
GET /api/deleteOrder/9001 // ✗ using GET to delete is catastrophic (prefetching, bots)
Some operations don't map cleanly to CRUD on a resource:
"cancel an order", "approve a payment", "send a password reset
email". Forcing these into resource semantics produces awkward
URLs like PATCH /orders/9001 with
{"status": "CANCELLED"}, which is technically correct
REST but reads like nothing the domain model actually says. The
pragmatic alternative — action sub-resources — is widely used in
production and accepted by most API style guides:
POST /orders/9001/cancellation is a resource
(the cancellation event) created by the action. It keeps the URL
meaningful without polluting it with verbs. Approach this as a
deliberate design decision, not a REST violation to apologize for.
// Action sub-resources: modeling state transitions as resource creation
POST /api/v1/orders/9001/cancellation // cancel order
POST /api/v1/orders/9001/confirmation // confirm order
POST /api/v1/payments/555/refunds // issue refund
POST /api/v1/users/42/password-resets // trigger password reset
// All are POST (creating a new event/state); all have meaningful nouns in the URL
Filtering, Sorting, and Pagination
// Query params for filtering and sorting — keep them consistent across resources
GET /api/v1/orders?status=PENDING&customerId=42&sort=createdAt,desc&page=0&size=20
@GetMapping
public Page<OrderResponse> getOrders(
@RequestParam(required = false) OrderStatus status,
@RequestParam(required = false) Long customerId,
@RequestParam(defaultValue = "createdAt,desc") String sort,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size) {
Pageable pageable = PageRequest.of(page, size, Sort.by(parseSortParam(sort)));
return orderService.findAll(status, customerId, pageable);
}
A Production-Grade REST Controller
The controller below follows all conventions introduced so far: record
DTOs, constructor injection, ProblemDetail errors (from
HTTP Protocol), proper status codes including
201 + Location, and @Valid on incoming
payloads. It deliberately does not return JPA entities.
@RestController
@RequestMapping("/api/v1/orders")
public class OrderController {
private final OrderService orderService;
public OrderController(OrderService orderService) {
this.orderService = orderService;
}
@GetMapping
public Page<OrderSummary> listOrders(
@RequestParam(required = false) OrderStatus status,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size) {
return orderService.findAll(status, PageRequest.of(page, size));
}
@GetMapping("/{id}")
public OrderDetail getOrder(@PathVariable Long id) {
return orderService.findById(id)
.orElseThrow(() -> new OrderNotFoundException(id));
}
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
public ResponseEntity<OrderDetail> createOrder(@Valid @RequestBody CreateOrderRequest request) {
OrderDetail created = orderService.create(request);
URI location = URI.create("/api/v1/orders/" + created.id());
return ResponseEntity.created(location).body(created);
}
@PatchMapping("/{id}")
public OrderDetail updateOrder(@PathVariable Long id, @Valid @RequestBody UpdateOrderRequest request) {
return orderService.update(id, request);
}
@DeleteMapping("/{id}")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void deleteOrder(@PathVariable Long id) {
orderService.delete(id);
}
@PostMapping("/{id}/cancellation")
public OrderDetail cancelOrder(@PathVariable Long id,
@Valid @RequestBody CancellationRequest request) {
return orderService.cancel(id, request.reason());
}
@GetMapping("/{id}/items")
public List<OrderItem> getItems(@PathVariable Long id) {
return orderService.findItems(id);
}
}
// Record DTOs — immutable, compact, no serialization surprises
public record CreateOrderRequest(@NotNull Long productId, @Positive int quantity) {}
public record CancellationRequest(@NotBlank String reason) {}
public record OrderDetail(Long id, OrderStatus status, BigDecimal total, Instant createdAt) {}
Error Responses: Stop Inventing Custom Formats
RFC 9457 (Problem Details) is built into Spring Boot 3.x. It provides
a machine-parseable error format with a defined schema so any
generic HTTP client can understand your errors. There is no good
reason to invent a custom ApiError class in 2026 —
you're adding code and inconsistency for no gain.
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(OrderNotFoundException.class)
public ProblemDetail handleNotFound(OrderNotFoundException ex) {
ProblemDetail pd = ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage());
pd.setTitle("Order not found");
pd.setProperty("orderId", ex.getOrderId());
return pd;
}
@ExceptionHandler(OrderAlreadyCancelledException.class)
public ProblemDetail handleConflict(OrderAlreadyCancelledException ex) {
ProblemDetail pd = ProblemDetail.forStatusAndDetail(HttpStatus.CONFLICT, ex.getMessage());
pd.setTitle("Order already cancelled");
return pd;
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public ProblemDetail handleValidation(MethodArgumentNotValidException ex) {
List<String> errors = ex.getBindingResult().getFieldErrors().stream()
.map(e -> e.getField() + ": " + e.getDefaultMessage())
.toList();
ProblemDetail pd = ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, "Validation failed");
pd.setProperty("errors", errors);
return pd;
}
}
// Wire format — Content-Type: application/problem+json
// {
// "type": "about:blank",
// "title": "Order not found",
// "status": 404,
// "detail": "No order with id 9001",
// "orderId": 9001
// }
API Versioning — Four Strategies and When to Use Each
| Strategy | Example | Pros | Cons |
|---|---|---|---|
| URL path | /api/v1/orders | Visible, cacheable, easy to test with browser/curl | URL changes on every major version; clients must update base URLs |
| Accept header | Accept: application/vnd.shop.v2+json | Semantically correct per HTTP; URL stays stable | Hard to test in browser; hidden from logs; breaks simple caches |
| Query param | /api/orders?version=2 | Easy to add to existing calls | Pollutes URLs; ignored by most API gateways for routing |
| Custom header | X-API-Version: 2 | Clean URLs | Non-standard; invisible to intermediaries; hard to route on |
The semantic purity arguments for header versioning are real but lose to practicality: URL-versioned APIs are trivially testable, routeable at the gateway level, and visible in access logs. The counter-argument ("it pollutes the URL") matters a lot less when your API has three major versions over ten years than it does in a dissertation. Use URL path versioning by default and only deviate with a concrete reason.
// URL path versioning — separate controller classes, cleanest isolation
@RestController
@RequestMapping("/api/v1/orders")
public class OrderControllerV1 { /* returns OrderV1 response shape */ }
@RestController
@RequestMapping("/api/v2/orders")
public class OrderControllerV2 { /* returns OrderV2 response shape — new fields, different structure */ }
// Accept header versioning — one controller, multiple produces values
@GetMapping(value = "/{id}", produces = "application/vnd.shop.v1+json")
public OrderV1 getOrderV1(@PathVariable Long id) { return orderService.findV1(id); }
@GetMapping(value = "/{id}", produces = "application/vnd.shop.v2+json")
public OrderV2 getOrderV2(@PathVariable Long id) { return orderService.findV2(id); }
HATEOAS — What It Is and When You Actually Need It
HATEOAS (Hypermedia As The Engine Of Application State) is the fourth sub-constraint of Uniform Interface. Responses include hypermedia links that describe available state transitions — the client discovers what it can do next from the response, rather than hard-coding URLs.
// Without HATEOAS — client hard-codes "/api/v1/orders/{id}/cancellation"
{
"id": 9001,
"status": "PENDING",
"total": 129.90
}
// With HATEOAS — client discovers the next available actions from the response itself
{
"id": 9001,
"status": "PENDING",
"total": 129.90,
"_links": {
"self": { "href": "/api/v1/orders/9001" },
"items": { "href": "/api/v1/orders/9001/items" },
"cancellation": { "href": "/api/v1/orders/9001/cancellation", "method": "POST" }
}
}
// Spring HATEOAS — add the starter: spring-boot-starter-hateoas
@GetMapping("/{id}")
public EntityModel<OrderDetail> getOrder(@PathVariable Long id) {
OrderDetail order = orderService.findById(id)
.orElseThrow(() -> new OrderNotFoundException(id));
return EntityModel.of(order,
linkTo(methodOn(OrderController.class).getOrder(id)).withSelfRel(),
linkTo(methodOn(OrderController.class).getItems(id)).withRel("items"),
linkTo(methodOn(OrderController.class).cancelOrder(id, null)).withRel("cancellation")
);
}
HATEOAS is architecturally correct — the client becomes
completely decoupled from URL structure and can follow links
without hard-coding paths. In practice, most production REST APIs
don't implement it, and most clients (mobile apps, SPAs, other
services) ignore _links even when they're present.
The overhead of maintaining hypermedia metadata on every response
rarely pays off unless you're building a public API that you
expect unknown third parties to consume and evolve against without
breaking. Internal APIs between services you control don't need
it. Public APIs where clients you don't control upgrade on their
own schedule potentially benefit. Evaluate it as an architectural
decision, not a checkbox.
REST vs GraphQL vs gRPC
REST is the default, but it's not always the right tool. The choice has real production consequences.
| Aspect | REST | GraphQL | gRPC |
|---|---|---|---|
| Query flexibility | Fixed shape per endpoint | Client specifies exact fields needed | Fixed shape per RPC method |
| Over/under-fetching | Common problem on mobile | Solved by design | Not a problem — methods are explicit |
| Type safety | None in HTTP layer; up to schema tooling | Enforced by schema | Strong — generated from .proto files |
| Browser support | Native | Via HTTP POST | Limited without grpc-web proxy |
| Performance | Good | Variable (N+1 risk) | Excellent — binary protocol, HTTP/2 |
| Caching | Standard HTTP caching | Complex (POST by default) | Application-level only |
| Best fit | Public APIs, most CRUD | APIs serving multiple clients with very different data needs (BFF pattern) | Internal service-to-service communication, streaming |
Large systems often combine all three: a REST API or GraphQL for the public-facing or browser-facing layer, and gRPC for internal service-to-service calls. gRPC's performance advantage is significant when services call each other thousands of times per second — but it's invisible to the end user and not worth the browser-compatibility cost on the public edge.
Best Practices and Common Pitfalls
✅ Do
- Model URLs as nouns (resources), use HTTP methods as the verbs — keep verbs out of URLs
- Return
201 Createdwith aLocationheader on successful POST — don't make clients guess the new resource's URL - Use
ProblemDetail(RFC 9457) for all error responses — it's in Spring Boot 3.x with zero setup and is the actual standard - Version your API from day one — adding versioning after the first breaking change is reactive and painful
- Use action sub-resources (
POST /orders/9001/cancellation) for state transitions that don't map to CRUD - Declare caching intent explicitly —
Cache-Control: max-agefor stable reads,no-cachefor volatile data
❌ Don't
- Don't invent a custom error body class —
ProblemDetailexists and is standard; any deviation is accidental complexity - Don't return
200 OKfor a failed creation — if it failed, the status code must say so; a 200 with an error in the body is the SOAP anti-pattern REST was created to escape - Don't expose database IDs in URLs if they're sequential integers on sensitive resources — they enable enumeration attacks; use UUIDs or opaque IDs
- Don't implement HATEOAS by default on internal APIs between services you control — the overhead is real and the benefit is only meaningful when the API consumer evolves independently of the API
- Don't choose gRPC for a public HTTP API unless you have a solid plan for browser access — the developer experience friction for external consumers is significant
Interview Questions
Q: What is the difference between REST and HTTP?
HTTP is the protocol — the wire format, the methods, the status codes.
REST is an architectural style that uses HTTP as its transfer
mechanism. You can have HTTP without REST (SOAP does), but REST as
typically practiced runs entirely over HTTP.
Q: Why should URLs contain nouns and not verbs?
The HTTP method is the verb — GET, POST, PUT, DELETE. Repeating it
in the URL (/getOrders) creates redundancy and breaks
the uniform interface: the same resource now has multiple addresses
depending on how you intend to use it, making caching, API gateways,
and tooling that operates on URLs all harder to reason about.
Q: What status code should a POST return on successful creation, and why?
201 Created, with a Location header pointing to the new
resource. 200 is incorrect because it signals that the request
succeeded but doesn't communicate that a new resource was created.
The Location header is what tells the client where to
find what it just created without making it construct the URL
itself.
Q: A domain operation like "cancel an order" doesn't fit GET/POST/PUT/PATCH/DELETE cleanly. How do you model it in a REST API without introducing verbs into the URL?
Model the state transition as the creation of a new subordinate
resource representing the event or the new state:
POST /orders/9001/cancellation. The
cancellation is a noun (the cancellation record), the
POST signals creation of that record, and the URL remains clean.
This approach also naturally gives you a persistent record of the
cancellation event, which is useful for audit trails. An alternative
used by some APIs is PATCH /orders/9001 with a body
expressing the desired state change — technically correct REST but
less expressive in the URL and potentially more ambiguous for clients
about what side effects to expect.
Q: Your API returns 200 OK with {"success": false, "error": "not found"}. Why is this a design problem even though it "works"?
It forces every client to parse the response body before knowing
whether the request succeeded — the entire point of HTTP status codes
is to signal success or failure at the protocol level so that HTTP
infrastructure (caches, load balancers, monitoring tools, log
aggregators) can act on it without understanding the payload. A 200
with a failure body will be cached by CDNs as a success, counted as
green by availability monitors, and require custom error-detection
logic in every client. It also violates REST's Uniform Interface
constraint — self-descriptive messages mean the HTTP response itself
carries its outcome, not a buried JSON field. Fix: return 404 (with
ProblemDetail) and let HTTP infrastructure do its job.
Q: When would you choose gRPC over REST for a new service, and what does that choice cost you?
gRPC is the right choice for internal service-to-service
communication where performance, strong type contracts, and
bidirectional streaming matter — think real-time order status updates
between services, or high-frequency inventory checks between a
catalog service and a pricing service. The protocol is binary (not
JSON), runs on HTTP/2, and generates client and server stubs from
.proto files, eliminating an entire class of contract drift bugs.
What it costs: browser support is near-zero without grpc-web and a
proxy layer, dev experience for ad-hoc testing requires specialized
tools (grpcurl, BloomRPC) instead of curl, and debugging on-wire
traffic is harder because it's binary. For a public HTTP API
intended to be consumed by browsers or third-party developers, REST
is still the overwhelmingly better default.