Lessons
1 · Java Foundations
2 · Object-Oriented Java
3 · Core Java
4 · Collections and Generics
5 · Functional Java
6 · The JVM and Concurrency
7 · Tools and Craft
- 21File I/O and Serialization
- 22Debugging: Errors, Stack Traces, the Debugger and Logging
- 23Maven: What It Does and How to Use It
- 24Git for Java Projects: Commits, Branches, Merging and Pull Requests
- 25Testing with JUnit and Mockito: Unit Tests, Mocks and Their Limits
- 26Project 1: Build an Expense Tracker CLI in Plain Java
8 · Databases and JDBC
9 · Web and Spring
10 · Persistence with JPA
11 · Secure, Tested, Production-Ready
- 36Security Fundamentals and Spring Security: Passwords, JWT, Access Control, CORS and Secrets
- 37Testing Spring Boot Applications: Slice Tests, MockMvc, Real Databases and the Test Pyramid
- 38Production Practices: Configuration, Logging, Docker and Health Checks for Spring Boot
- 39Project 3: The Job Application Tracker API, Assembled and Reviewed
Lesson 30 · Web and Spring
HTTP, JSON and REST APIs: How Web Backends Talk
Understand what actually travels between a client and a Java backend: the HTTP request and response, methods and their guarantees, status codes, headers, JSON and how Java maps to it, and how to design a clean REST API, with real captured traffic and real JSON mapping.
For
Learners who can build Java programs and databases and now need to understand the protocol and conventions of web APIs before using Spring Boot.
Level
Before you start
Records (article 9), exceptions (article 10) and interfaces (article 6). No web knowledge is assumed. The programs ran on JDK 21 and JDK 25, and the JSON examples use Jackson 3.1.5, the version that Spring Boot 4.1.1 includes.
On this page
Key takeaways
- HTTP is a text protocol of requests and responses. A request has a method, a path, headers and an optional body. A response has a status code, headers and an optional body.
- GET is safe and idempotent, PUT and DELETE are idempotent, POST is neither. Idempotent means that repeating the request leaves the server in the same state as sending it once, which matters when a client retries after a timeout.
- Status codes tell the client what happened: 2xx success, 4xx the client's mistake, 5xx the server's. Choose them deliberately, for example 201 with a Location header for a created resource and 404 for a missing one.
- JSON is the usual body format. Libraries such as Jackson convert between JSON and Java objects, and the conversion silently accepts missing fields, so a real API must validate its input.
- A REST API is organised around resources named by nouns in URLs, manipulated with the standard methods, with consistent errors, filtering and paging.
Almost every backend job involves building or calling web APIs: a mobile app, a browser front end or another service sends a request over the network, and your Java code sends back a response. Frameworks such as Spring hide the plumbing, but you cannot debug, design or secure an API without knowing what actually travels over the wire. This article shows it, with real captured traffic.
What you will learn
#- What an HTTP request and response contain, shown as real bytes.
- The methods, their guarantees (safe, idempotent) and the status codes.
- What JSON is and how Java objects map to it.
- How to design a clean REST API, with errors, filtering and paging.
Clients, servers and URLs
#A client (a browser, a phone app, a Java program) sends a request to a server, which sends back a response. Each request is independent. The server does not remember the previous one unless you add a mechanism to do so, which is why HTTP is called stateless. Statelessness is a major reason web servers scale: any server can answer any request.
A URL says where a resource is:
https://api.example.com:8443/api/expenses/42?month=2026-09#details\___/ \_____________/\___/\_____________/ \__________/ \_____/scheme host port path query fragmentPart | Meaning |
|---|---|
Scheme | The protocol: |
Host and port | Which machine and which service on it (the defaults are 80 for |
Path | Which resource on the server |
Query | Extra parameters as |
Fragment | Used by the browser only. It is never sent to the server |
HTTPS is HTTP inside an encrypted TLS connection. It protects against eavesdropping and tampering, and lets the client verify the server through a certificate. Credentials, tokens and personal data must never travel over plain HTTP. (HTTP/2 and HTTP/3 change how the bytes are carried, but the ideas on this page are the same.)
An HTTP exchange, as bytes
#HTTP/1.1 is a text protocol, and you can read it. The program below starts a tiny HTTP server inside the JVM, using only classes in the JDK, and then does two things. First it connects with a plain socket and sends a handwritten request, so that you see the raw text going out and coming back. Then it uses the JDK's HttpClient to call the API with several methods and prints the status codes. The server is a toy: it stores the JSON text it is given and validates nothing.
// File: Main.javaimport com.sun.net.httpserver.HttpExchange;import com.sun.net.httpserver.HttpServer;import java.io.BufferedReader;import java.io.IOException;import java.io.InputStreamReader;import java.io.OutputStream;import java.net.InetSocketAddress;import java.net.Socket;import java.net.URI;import java.net.http.HttpClient;import java.net.http.HttpRequest;import java.net.http.HttpResponse;import java.nio.charset.StandardCharsets;import java.util.Map;import java.util.TreeMap;public class Main { // A tiny in-memory "expenses" resource. It stores whatever JSON text it is given and does no validation. static final Map<Integer, String> STORE = new TreeMap<>(); static int nextId = 2; static void handle(HttpExchange exchange) throws IOException { String method = exchange.getRequestMethod(); String[] parts = exchange.getRequestURI().getPath().split("/"); // "", "expenses", maybe "1" Integer id = parts.length > 2 ? Integer.valueOf(parts[2]) : null; String body = new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8); String contentType = exchange.getRequestHeaders().getFirst("Content-Type"); if (id == null) { // the collection: /expenses switch (method) { case "GET" -> send(exchange, 200, "[" + String.join(",", STORE.values()) + "]"); case "POST" -> { if (contentType == null || !contentType.startsWith("application/json")) { send(exchange, 415, "{\"error\":\"send application/json\"}"); return; } int newId = nextId++; STORE.put(newId, body); exchange.getResponseHeaders().add("Location", "/expenses/" + newId); send(exchange, 201, body); } default -> { exchange.getResponseHeaders().add("Allow", "GET, POST"); send(exchange, 405, "{\"error\":\"method not allowed\"}"); } } return; } switch (method) { // one item: /expenses/{id} case "GET" -> { String found = STORE.get(id); send(exchange, found == null ? 404 : 200, found == null ? "{\"error\":\"not found\"}" : found); } case "PUT" -> { STORE.put(id, body); // replace, or create at this id send(exchange, 200, body); } case "DELETE" -> { if (STORE.remove(id) == null) { send(exchange, 404, "{\"error\":\"not found\"}"); } else { send(exchange, 204, null); } } default -> { exchange.getResponseHeaders().add("Allow", "GET, PUT, DELETE"); send(exchange, 405, "{\"error\":\"method not allowed\"}"); } } } static void send(HttpExchange exchange, int status, String body) throws IOException { if (body == null) { exchange.sendResponseHeaders(status, -1); // no body } else { byte[] bytes = body.getBytes(StandardCharsets.UTF_8); exchange.getResponseHeaders().add("Content-Type", "application/json"); exchange.sendResponseHeaders(status, bytes.length); try (OutputStream out = exchange.getResponseBody()) { out.write(bytes); } } exchange.close(); } static void rawExchange(int port) throws IOException { String request = "GET /expenses/1 HTTP/1.1\r\n" + "Host: localhost\r\n" + "Accept: application/json\r\n" + "Connection: close\r\n" + "\r\n"; System.out.println("--- bytes sent by the client ---"); System.out.print(request.replace("\r\n", "\n")); System.out.println("--- bytes received ---"); try (Socket socket = new Socket("127.0.0.1", port)) { socket.getOutputStream().write(request.getBytes(StandardCharsets.US_ASCII)); BufferedReader in = new BufferedReader(new InputStreamReader(socket.getInputStream(), StandardCharsets.UTF_8)); String line; while ((line = in.readLine()) != null) { if (!line.startsWith("Date:")) { // the time changes on every run System.out.println(line); } } } System.out.println(); } static void call(HttpClient client, String method, String url, String contentType, String body) throws Exception { HttpRequest.Builder builder = HttpRequest.newBuilder(URI.create(url)); HttpRequest.BodyPublisher publisher = body == null ? HttpRequest.BodyPublishers.noBody() : HttpRequest.BodyPublishers.ofString(body); builder.method(method, publisher); if (contentType != null) { builder.header("Content-Type", contentType); } HttpResponse<String> response = client.send(builder.build(), HttpResponse.BodyHandlers.ofString()); String path = URI.create(url).getPath(); StringBuilder line = new StringBuilder(String.format("%-6s %-14s -> %d", method, path, response.statusCode())); response.headers().firstValue("Location").ifPresent(v -> line.append(" Location: ").append(v)); response.headers().firstValue("Allow").ifPresent(v -> line.append(" Allow: ").append(v)); System.out.println(line); } public static void main(String[] args) throws Exception { STORE.put(1, "{\"id\":1,\"amountCents\":1250,\"description\":\"lunch with Sam\"}"); HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); server.createContext("/expenses", exchange -> { try { handle(exchange); } catch (Exception e) { e.printStackTrace(); exchange.sendResponseHeaders(500, -1); exchange.close(); } }); server.start(); int port = server.getAddress().getPort(); String base = "http://127.0.0.1:" + port; try { rawExchange(port); HttpClient client = HttpClient.newHttpClient(); String json = "application/json"; String item = "{\"amountCents\":450,\"description\":\"bus\"}"; call(client, "GET", base + "/expenses/1", null, null); call(client, "GET", base + "/expenses/99", null, null); call(client, "POST", base + "/expenses", json, item); call(client, "POST", base + "/expenses", json, item); call(client, "POST", base + "/expenses", "text/plain", "450 bus"); call(client, "PUT", base + "/expenses/1", json, item); call(client, "PUT", base + "/expenses/1", json, item); call(client, "DELETE", base + "/expenses/2", null, null); call(client, "DELETE", base + "/expenses/2", null, null); call(client, "PATCH", base + "/expenses/1", json, item); call(client, "DELETE", base + "/expenses", null, null); System.out.println("stored now: " + STORE.keySet()); } finally { server.stop(0); } }}--- bytes sent by the client ---GET /expenses/1 HTTP/1.1Host: localhostAccept: application/jsonConnection: close--- bytes received ---HTTP/1.1 200 OKContent-type: application/jsonContent-length: 58{"id":1,"amountCents":1250,"description":"lunch with Sam"}GET /expenses/1 -> 200GET /expenses/99 -> 404POST /expenses -> 201 Location: /expenses/2POST /expenses -> 201 Location: /expenses/3POST /expenses -> 415PUT /expenses/1 -> 200PUT /expenses/1 -> 200DELETE /expenses/2 -> 204DELETE /expenses/2 -> 404PATCH /expenses/1 -> 405 Allow: GET, PUT, DELETEDELETE /expenses -> 405 Allow: GET, POSTstored now: [1, 3]The raw exchange
#The first part of the output is the whole conversation:
- The request line
GET /expenses/1 HTTP/1.1holds the method (what to do), the path (which resource) and the protocol version. - Headers are
Name: valuelines.Hostsays which site is wanted (one server can host many),Acceptsays which formats the client can handle, andConnection: closeasks the server to close the connection after answering. - A blank line ends the headers. A request may then have a body, which this one does not, because a
GETnormally carries no body. - The response status line
HTTP/1.1 200 OKholds the status code and its reason phrase. - Response headers:
Content-typesays that the body is JSON, andContent-lengthsays how many bytes follow. Header names are case-insensitive, which is why this server'sContent-typeis the same header as theContent-Typeyour code sets. (A real server also sends aDateheader, which the program filters out so that the output is repeatable.) - After another blank line comes the body: the JSON.
That is all HTTP is: a request in this shape, a response in that shape. A browser, curl, Postman and Spring all produce and parse exactly this.
Methods and what they promise
#The method says what the client wants to do. Two properties from the HTTP standard (RFC 9110) matter for API design:
- A method is safe if it is meant only to read, so the client does not ask for any change.
GETis safe. - A method is idempotent if making the same request several times has the same effect on the server's state as making it once.
GET,PUTandDELETEare idempotent.POSTis not.
Method | Typical use | Safe | Idempotent |
|---|---|---|---|
| Read a resource or a list | Yes | Yes |
| Create a resource, or run an action | No | No |
| Replace a resource entirely at a known URL | No | Yes |
| Change part of a resource | No | Not guaranteed |
| Remove a resource | No | Yes |
The second part of the program's output demonstrates this on a real server. Read it top to bottom:
POST /expensestwice gave two201 Createdresponses with differentLocationheaders (/expenses/2and/expenses/3): sending aPOSTtwice created two resources. This is why a client that is unsure whether itsPOSTarrived cannot safely just repeat it.PUT /expenses/1twice gave200both times and left the same state: replacing a resource with the same content again changes nothing.DELETE /expenses/2returned204 No Content, and the second identicalDELETEreturned404. The server state after the second call is the same (the resource is gone), which is what idempotent means, even though the response differed.PATCHon a resource that does not allow it, andDELETEon the collection, returned405 Method Not Allowedwith anAllowheader listing what is permitted.POSTwithContent-Type: text/plainreturned415 Unsupported Media Type, because the server accepts only JSON.- At the end,
stored now: [1, 3]: resource 1 exists (replaced), 2 was deleted, 3 was created by the secondPOST.
Why idempotency matters: networks fail. If a client sends a request and the connection drops before the response arrives, it does not know whether the server processed it. For an idempotent request it can safely retry. For a POST that creates an order or charges a card, a blind retry could do it twice. APIs solve this with an idempotency key: the client sends a unique key in a header, and the server remembers it and returns the original result for a repeated key.
Status codes
#The status code is a three-digit number whose first digit gives the class. Use the code that is correct for the situation, because clients, proxies, monitoring tools and other developers rely on it.
Code | Name | Use it when |
|---|---|---|
| OK | A successful read or update with a body |
| Created | A resource was created. Add a |
| Accepted | The request was accepted for later processing |
| No Content | Success with nothing to return (a delete) |
| Moved | The resource lives at another URL |
| Not Modified | The client's cached copy is still valid |
| Bad Request | The request is malformed or invalid (bad JSON, a failed validation) |
| Unauthorized | The client is not authenticated (the name is historical: it means "who are you?") |
| Forbidden | The client is authenticated but not allowed to do this |
| Not Found | No such resource |
| Method Not Allowed | The resource does not support that method. Send an |
| Conflict | The request conflicts with the current state (a duplicate, an edit conflict) |
| Unsupported Media Type | The body is in a format the server does not accept |
| Unprocessable Content | The request is well-formed but semantically invalid (some APIs use this for validation failures, others use 400) |
| Too Many Requests | The client is being rate-limited |
| Internal Server Error | A bug or unexpected failure on the server |
| Bad Gateway, Service Unavailable, Gateway Timeout | A dependency or the service itself is unavailable |
The most important distinction: 4xx means the client should change something, 5xx means the server failed. A validation error answered with 500 makes monitoring page you at night for a user's typo, and a server crash answered with 400 hides your bug. Another pair that is often mixed up is 401 and 403, covered in article 36. Never put stack traces, SQL or internal class names in an error response, because they help an attacker.
Headers you will meet
#Header | Direction | Purpose |
|---|---|---|
| Both | The format of the body ( |
| Request | The formats the client can handle |
| Both | The size of the body in bytes |
| Response | The URL of a newly created resource |
| Request | Credentials or a token |
| Response | Rules for caching, and a version tag for conditional requests |
| Response | The methods a resource supports (with |
| Both | Cross-origin browser rules, covered in the security article |
JSON
#JSON (JavaScript Object Notation, RFC 8259) is the usual format for API bodies: text, readable, and understood by every language. It has exactly six kinds of value:
{ "id": 42, "description": "lunch with Sam", "amountCents": 1250, "paid": true, "category": null, "tags": ["work", "shared"], "receipt": { "number": "R-17", "pages": 1 }}JSON type | Example | Java type it usually maps to |
|---|---|---|
Object |
| A class or record, or a |
Array |
| A |
String |
|
|
Number |
|
|
Boolean |
|
|
|
|
|
The rules that catch people: strings and keys use double quotes only, there are no comments and no trailing commas, and every document is a single value (usually an object or an array). Mistakes in these cause parse errors.
Design conventions that avoid trouble:
- Dates and times are strings in ISO 8601 format (
"2026-09-03","2026-09-03T10:15:00Z"), because JSON has no date type. - Money is an integer in the smallest unit (
1250cents) or a decimal string, never a floating-point number that you compute with (article 2). - Large integers: JSON itself does not limit number size, but JavaScript clients read numbers as double-precision floats, which are exact only up to 2^53 − 1. A 64-bit id above that can silently change in a browser, so APIs sometimes send such ids as strings.
nulland "absent" are different in principle. Decide what each means in your API, particularly for partial updates.- Use consistent names, usually
camelCase.
Java objects to JSON and back
#Converting between JSON and Java objects is called serialisation and deserialisation (or marshalling). In the Spring world the library is Jackson. Spring Boot 4 uses Jackson 3, whose packages start with tools.jackson (the annotations still live in com.fasterxml.jackson.annotation). This program, built with Maven and run on JDK 21 and 25 with identical output, shows the mapping and what happens with bad input:
// File: src/main/java/com/example/jsondemo/JsonDemo.javapackage com.example.jsondemo;import java.time.LocalDate;import java.util.List;import tools.jackson.core.JacksonException;import tools.jackson.core.type.TypeReference;import tools.jackson.databind.SerializationFeature;import tools.jackson.databind.json.JsonMapper;public class JsonDemo { record Expense(long id, LocalDate spentOn, long amountCents, String category, String description) { } record NewExpense(LocalDate spentOn, long amountCents, String category, String description) { } static void tryRead(JsonMapper mapper, String label, String json) { try { System.out.println(label + ": " + mapper.readValue(json, NewExpense.class)); } catch (JacksonException e) { String message = e.getOriginalMessage(); if (message.length() > 90) { message = message.substring(0, 90) + "..."; // the full message is very long } System.out.println(label + ": " + e.getClass().getSimpleName() + " - " + message); } } public static void main(String[] args) { JsonMapper mapper = JsonMapper.builder().build(); JsonMapper pretty = JsonMapper.builder().enable(SerializationFeature.INDENT_OUTPUT).build(); Expense expense = new Expense(1, LocalDate.of(2026, 9, 3), 1250, "FOOD", "lunch with Sam"); System.out.println("Java object to JSON:"); System.out.println(pretty.writeValueAsString(expense)); String body = "{\"spentOn\":\"2026-09-05\",\"amountCents\":4500,\"category\":\"TRANSPORT\"," + "\"description\":\"metro pass\"}"; NewExpense received = mapper.readValue(body, NewExpense.class); System.out.println("JSON to Java object:"); System.out.println(received); String list = "[{\"id\":1,\"spentOn\":\"2026-09-03\",\"amountCents\":1250,\"category\":\"FOOD\",\"description\":\"a\"}," + "{\"id\":2,\"spentOn\":\"2026-09-05\",\"amountCents\":4500,\"category\":\"TRANSPORT\",\"description\":null}]"; List<Expense> expenses = mapper.readValue(list, new TypeReference<List<Expense>>() { }); System.out.println("A JSON array becomes a list of " + expenses.size() + ", the second description is " + expenses.get(1).description()); System.out.println("Bad input:"); tryRead(mapper, "missing fields ", "{\"amountCents\":100}"); tryRead(mapper, "unknown field ", "{\"amountCents\":100,\"colour\":\"red\"}"); tryRead(mapper, "wrong type ", "{\"amountCents\":\"lots\"}"); tryRead(mapper, "invalid date ", "{\"spentOn\":\"05/09/2026\",\"amountCents\":100}"); tryRead(mapper, "not JSON at all ", "{\"amountCents\": }"); }}Java object to JSON:{ "id" : 1, "spentOn" : "2026-09-03", "amountCents" : 1250, "category" : "FOOD", "description" : "lunch with Sam"}JSON to Java object:NewExpense[spentOn=2026-09-05, amountCents=4500, category=TRANSPORT, description=metro pass]A JSON array becomes a list of 2, the second description is nullBad input:missing fields : NewExpense[spentOn=null, amountCents=100, category=null, description=null]unknown field : NewExpense[spentOn=null, amountCents=100, category=null, description=null]wrong type : InvalidFormatException - Cannot deserialize value of type `long` from String "lots": not a valid `long` valueinvalid date : InvalidFormatException - Cannot deserialize value of type `java.time.LocalDate` from String "05/09/2026": Failed to...not JSON at all : StreamReadException - Unexpected character ('}' (code 125)): expected a valueWhat this teaches:
- A record maps naturally to a JSON object. The date became an ISO string, and
longbecame a number. Jackson 3 reads and writesjava.timetypes by default. - Reading is just as direct, and
TypeReferencelets Jackson handle a generic type such asList<Expense>, a consequence of type erasure (article 14). A JSONnullbecomes a Javanull. - Missing fields do not cause an error.
{"amountCents":100}produced an object withspentOn,categoryanddescriptionallnull. The mapper converts, it does not validate. If your API trusts it, an invalid request creates an invalid object. Article 33 adds Bean Validation for this. - Unknown fields were ignored by the default settings of this Jackson version. This is configurable, and the behaviour has differed between libraries and versions, so decide deliberately and test it.
- Wrong types and malformed JSON throw exceptions (
InvalidFormatException,StreamReadException). In a web application those must be turned into a400 Bad Requestwith a clear message, not a500.
What REST means
#REST (Representational State Transfer) is an architectural style for web APIs, and "REST API" in practice means an HTTP API with these characteristics:
- Resources are the nouns of your domain (an expense, a job application), each with a URL. The client works with representations of them (usually JSON).
- The standard methods express the operation, not the URL. You do not write
/getExpenseor/deleteExpense. - Stateless requests: each request carries what the server needs.
- Standard status codes and headers carry the outcome.
- A uniform, predictable structure so that a developer who has seen one endpoint can guess the others.
Designing the URLs
#GET /api/expenses list expenses (supports filtering and paging)POST /api/expenses create an expenseGET /api/expenses/42 read onePUT /api/expenses/42 replace onePATCH /api/expenses/42 change some fieldsDELETE /api/expenses/42 delete oneGET /api/expenses/summary an aggregate view of the collectionRules of thumb:
- Use plural nouns for collections (
/expenses), and the id for one item (/expenses/42). - Nest only when the child makes no sense without the parent:
/applications/7/notesfor the notes of application 7. Avoid nesting more than one or two levels. - Put filtering, sorting and paging in query parameters:
/api/expenses?month=2026-09&category=FOOD&sort=spentOn,desc&page=0&size=20. - Do not put verbs or actions in the path when a method expresses it. For something that is truly an action, a sub-resource noun is acceptable (
POST /applications/7/withdrawal), and do not useGETfor anything that changes state. - Do not leak your database design: the URL structure serves the client's needs, not your tables.
- Version the API when you must make a breaking change, for example with
/api/v1/..., and avoid breaking changes whenever possible: adding a field is compatible, removing or renaming one is not.
What each operation should return
#Operation | Success | Typical errors |
|---|---|---|
List ( |
|
|
Read one |
|
|
Create ( |
|
|
Replace ( |
|
|
Delete |
|
|
Paging
#A list that can grow must be paged: never return a million rows. The common approach is page and size (or offset and limit) parameters, with the response saying how many items there are in total. It is simple, but a deep page is slow in the database (article 27's OFFSET) and results can shift while the client pages. Cursor-based paging (?after=<last id seen>) avoids both. Choose one, apply it to every collection, and set a maximum page size so a client cannot ask for everything.
Errors
#Every failure should have the same shape, so a client can handle it generically. RFC 9457 defines a standard format called problem details, with the media type application/problem+json:
{ "type": "https://example.com/problems/validation-error", "title": "Invalid expense", "status": 400, "detail": "amountCents must be greater than zero", "instance": "/api/expenses"}status repeats the HTTP status, title is a short summary, detail explains this occurrence, and type identifies the kind of problem. You may add fields, such as a list of the invalid fields. Spring supports this format directly, as article 33 shows. The rules: a stable machine-readable structure, a message the client developer can act on, and no internal details (stack traces, SQL, class names).
Looking ahead: the Job Application Tracker
#Project 3 builds an API for tracking job applications. Sketching the resources now shows the method:
Resource | Endpoints |
|---|---|
Applications |
|
Status changes |
|
Notes on an application |
|
Filtering |
|
Tools for trying APIs
#The command-line tool curl, graphical clients such as Postman and Bruno, your browser's developer tools, and a Java HttpClient all send requests like the ones above. A typical curl call to create an expense is curl -i -X POST http://localhost:8080/api/expenses -H "Content-Type: application/json" -d '{"amountCents":450}', where -i prints the response headers and -d supplies the body. You will use it against the real Spring Boot API in the next articles.
Common mistakes
#- Using
GETfor actions that change data, which breaks caching, crawlers, prefetching and the guarantees clients rely on. - Returning
200with an error message in the body instead of a proper status code. - Answering every failure with
500, or every client error with400without a useful message. - Verbs in URLs (
/createExpense) instead of resources and methods. - Trusting incoming JSON because Jackson accepted it. Missing fields are
null, so validate. - Returning database entities directly as the API shape, so a schema change breaks clients.
- Unbounded lists with no paging.
- Leaking stack traces or SQL errors in responses.
- Sending secrets in URLs. URLs are logged by servers, proxies and browsers. Put credentials in headers or bodies, over HTTPS.
- Retrying
POSTblindly after a timeout.
Exercises
#Exercise 1
Choose method and status
#For each, state the method, a suitable URL and the success status code: (a) list this month's expenses, (b) add an expense, (c) remove expense 7, (d) read expense 7 when it does not exist, (e) change only the description of expense 7.
Exercise 2: Fix the JSON
#What is wrong with each, and what is the correct JSON?
{ 'amount': 450, "description": "bus", }{ "amount": 450, // cents "description": "bus" }{ "amount": 4.50 "description": "bus" }Exercise 3: Which code?
#Choose a status code and give a reason: (a) a request with the JSON {"amountCents": cut off, (b) a valid user asks for another user's data they may not see, (c) a user who sent no token, (d) the database is down, (e) creating an application that already exists with the same unique reference.
Exercise 4: The retry
#A mobile app sends POST /api/payments, loses the connection, and does not know whether the payment was created. What can go wrong if it simply retries, and how does an API fix that?
Exercise 5: Design the endpoints
#Design the endpoints for notes on a job application: add a note, list the notes of one application, delete a note, with status codes.
Show solutionHide solution
(a) GET /api/expenses?month=2026-09, 200. (b) POST /api/expenses, 201 with a Location header. (c) DELETE /api/expenses/7, 204. (d) GET /api/expenses/7 returns 404. (e) PATCH /api/expenses/7 with {"description":"new text"}, 200 with the updated resource (PUT would need the whole resource).
Exercise 2
Show solutionHide solution
First: JSON needs double quotes around keys and strings, and has no trailing comma. Second: JSON has no comments. Third: a comma is missing between the members. The correct versions are all the same document, which is valid JSON:
{ "amount": 450, "description": "bus" }For a decimal amount, 4.50 is a valid JSON number, but money is better sent as integer cents.
Exercise 3
Show solutionHide solution
(a) 400, because the body is malformed JSON, so it is a client error. (b) 403, because the client is authenticated but not allowed. (Some APIs return 404 instead so as not to reveal that the data exists, which is a deliberate choice.) (c) 401, because the client has not authenticated. (d) 503 Service Unavailable (or 500 if you treat it as an unexpected failure): it is the server's problem, not the client's. (e) 409 Conflict, because the request conflicts with the existing state.
Exercise 4
Show solutionHide solution
If the first request actually succeeded, the retry creates a second payment, because POST is not idempotent, so the customer is charged twice. The usual fix is an idempotency key: the client generates a unique value for the payment and sends it in a header (such as Idempotency-Key) with every attempt. The server stores the key with the result, and when it sees the same key again, it returns the original result instead of creating another payment.
Exercise 5
Show solutionHide solution
POST /api/applications/{id}/notes returns 201 with a Location of the new note, 404 if the application does not exist, and 400 if the body is invalid. GET /api/applications/{id}/notes returns 200 with an array (empty if there are none), and 404 if the application does not exist. DELETE /api/applications/{id}/notes/{noteId} returns 204, and 404 if either does not exist. The note is nested because it has no meaning without its application. The server must also check that the note belongs to that application, so one cannot delete another application's note by guessing an id.
Where to go next
#- Next: Dependency Injection and Inversion of Control starts the Spring half of the course with the idea behind the framework.
- Then: Building a REST API with Spring Boot turns every concept on this page into working endpoints.
- For the security of these requests: Security Fundamentals and Spring Security.
Sources & further reading
- Technology
- Software Engineering
- Web Development
- #HTTP
- #Jackson
- #Java
- #Java backend
- #Java interview
- #JPA
- #JSON
- #learn Java
- #REST API
- #Spring Boot
- #SQL
Connected lessons
References
Linked from 2 guides
Other pages in the library that point here.
Keep reading
More in Technology →Guide · Technology
Heaps and Priority Queues
Guide · Technology
Dynamic Programming Practice: From 1D to 2D
Course · Technology
DSA From Zero to Job-Ready: A Practical Guide with Java

