API Design

Request and Response Design: JSON Shapes, Errors and Validation

Consistent JSON, naming, dates and money, one error format (RFC 9457 problem details), validation with clear messages, and what never to return.

Lesson 0214 min read#api#json#errors#validation

On this page

  1. Words used in this lesson
  2. Part 1: Make the shapes consistent
  3. Part 2: What never to return, and what never to accept
  4. Part 3: One error format, RFC 9457
  5. Part 4: Validation that helps
  6. Part 5: A response design checklist
  7. Try it
  8. Interview questions
  9. Key takeaways
  10. What's next

In one line: a good request and response is boring and the same everywhere: one naming style, dates in one format, money as whole numbers, a JSON object at the top, and one error shape (RFC 9457 "problem details") so a client can handle every failure with the same code.

The URLs and methods are only half of an API. The other half is what travels in the body. Here is where small choices pile up into pain. One endpoint says createdAt, another says created_date, a third says timestamp. One returns the price as 19.99, another as "19,99 €". Errors come as {"error":"bad"} from one route, {"message":["x","y"]} from a second, and an HTML page from the third when the server crashes.

A client developer then writes special code for every route. That is the cost of inconsistency, and this lesson removes it.

This is lesson 2 of ten. Before it: HTTP Methods, Status Codes and Idempotency. After it: Pagination, Filtering and Sorting. For input checking inside a Node backend, Backend Lesson 3 (validation at the edge) is a good companion, and Security Lesson 1 shows why input must never be trusted.

What I ran / checked. Everything ran on Linux with Node.js v22.22.0, the built-in node:http and node:sqlite modules, and curl. I wrote two small servers, one that does it wrong and one that does it right, and pasted their real output. The timestamps in the output will be different when you run it. Sources I read (own words): the Zalando RESTful API Guidelines chapter on JSON (snake_case names, date and time naming, null vs absent, top-level objects, the common money object, enum style) and on HTTP status codes (the "support problem JSON" rule); the Microsoft Azure REST API Guidelines section on handling errors (the error object with code, message, target, details, innererror); and Google AIP-193 (errors) by its headings. Could not reach: the RFC Editor and IETF sites were blocked, so I did not read RFC 9457 directly. A web search result confirmed its title ("Problem Details for HTTP APIs", July 2023), the media type application/problem+json, and that it has type, title, detail, instance, and extension members; the status member and the exact wording come from my own knowledge and from the Zalando guideline that cites it. Please check the RFC before you quote it. I did not check GitHub star counts.

Words used in this lesson

  • Payload is the data in a request or response body.
  • Schema is a description of the shape of the data: which fields, which types, which are required.
  • snake_case / camelCase / kebab-case are ways to write names with several words: created_at, createdAt, created-at.
  • ISO 8601 / RFC 3339 are standard text formats for dates and times, such as 2026-03-08T14:30:00Z.
  • UTC is the time zone with no offset. The Z at the end of a timestamp means UTC.
  • Minor units means the smallest part of a currency as a whole number: cents for euros and dollars. 19.99 EUR is 1999 minor units.
  • Floating point is how computers store decimal numbers like 0.1. It is not exact.
  • Null is the JSON value that means "empty on purpose". Absent means the field is not in the JSON at all.
  • Problem details is the standard JSON error format from RFC 9457, with the media type application/problem+json.
  • Validation is checking that input has the right shape and values before using it.
  • Mass assignment is a bug where the server copies every field the client sent into the database, including fields the client must not set.
  • DTO (Data Transfer Object) is a plain object that describes the public shape of a request or response, separate from the database row.
  • Extension member is a custom field you add to a standard format, such as an errors list in a problem details object.

Part 1: Make the shapes consistent

Always return an object

We said it in lesson 0: return {"items":[...]} and not [...]. The Zalando guidelines have this as a rule (the top-level value must be a JSON object). With an object, you can add next_cursor, warnings or total later without breaking old clients. The same goes for a single resource: return the object, not a bare string or number.

One naming style

Pick one and never mix. The common choices:

StyleExampleSeen in
snake_casecreated_atZalando's rules; GitHub, Stripe
camelCasecreatedAtMany JavaScript-first APIs, Google's JSON APIs, Microsoft's

There is no official winner. The Zalando guideline says there is "no established industry standard" and that the real aim is for JSON to look like it came from the same hand. I use snake_case in this series. If your team writes only JavaScript, camelCase saves a mapping step. The one wrong answer is both.

A few smaller naming rules that are easy to follow:

  • Plural names for arrays (items, tags), singular for objects (address).
  • Name dates and times so you can see their type. Zalando suggests the _at suffix for a date-time (created_at, shipped_at) and a name with date in it for a date-only value (birth_date). This stops confusion between created (is it a boolean? a date?) and created_at.
  • Enums as strings, not numbers. "status": "PAID" is readable in logs. "status": 3 is a riddle. Zalando prefers UPPER_SNAKE_CASE values (PAID, PARTIALLY_REFUNDED), others use lowercase. Pick one.
  • Booleans are true or false, never null. A third state hides a bug. Zalando says not to use null for booleans.
  • Empty list is [], not null. Then client code can always loop.
  • Do not reuse a field name for a different meaning, and do not reuse a type. If amount is a number in one place and an object in another, the client needs two parsers.

Null vs absent: decide once

In JSON a field can be null, absent, or have a value. Does {"nickname": null} mean the same as {}? The Zalando guideline says: use the same meaning for null and for absent. This keeps clients simple. The exception is PATCH: in a merge patch, absent means "do not change" and null means "clear it". Whatever you choose, write it in the docs. A tiny test shows that JavaScript itself blurs the lines:

JavaScript
console.log(JSON.stringify({ a: undefined, b: null, c: [], d: NaN }));
TEXT
{"b":null,"c":[],"d":null}

undefined disappears (the field becomes absent). NaN turns into null without any error. So a calculation bug in your server can quietly appear as null in the response. Validate numbers before you send them.

Big numbers: send ids as strings

JavaScript numbers are 64-bit floats. Whole numbers are exact only up to 2^53 - 1 (9007199254740991). A larger id read in a browser or in Node is silently wrong:

TEXT
$ node bigint.mjs9007199254740992 90071992547409939007199254740991

The first value is the JSON number 9007199254740993, and JavaScript gave back ...992. The second is the same id as a string, which stayed correct. Many APIs that use 64-bit ids return them as strings for this reason (some even include both forms). A random UUID or a long random string is a string anyway. If your ids can pass 2^53, make them strings.

Dates and times: one format, in UTC

Dates are where APIs cause the most real-world bugs. Here is what JavaScript prints for the same moment, in two time zones:

TEXT
$ TZ=Asia/Kolkata node numbers.mjstoString        : Sun Mar 08 2026 20:00:00 GMT+0530 (India Standard Time)toLocaleString  : 3/8/2026, 8:00:00 PMtoLocaleString  : 08/03/2026, 20:00:00toISOString     : 2026-03-08T14:30:00.000ZDate.parse('03/04/2026') -> 2026-03-03T18:30:00.000Z$ TZ=America/New_York node numbers.mjs        (same moment, other zone)toString        : Sun Mar 08 2026 10:30:00 GMT-0400 (Eastern Daylight Time)toLocaleString  : 3/8/2026, 10:30:00 AM

The same instant looks different everywhere. 3/8/2026 is the 8th of March in the US format, and 08/03/2026 is the same day in the British format. A string like 03/04/2026 is 3 April for one reader and 4 March for another. Node read it as 4 March in local time (the last line shows that in UTC it is 18:30 on the 3rd, because my test zone was +05:30). If your API sends any of these, you force every client to guess.

The rule: send a date-time as an RFC 3339 / ISO 8601 string in UTC, like 2026-03-08T14:30:00.000Z (only toISOString() gives you exactly this). The Z means UTC. The client turns it into local time when it shows it to a person. For a date with no time of day (a birthday, a due date), use 2026-03-08. Store the same way. Do not send Unix numbers unless you have a strong reason, because a number gives you no clue about its unit (seconds or milliseconds?).

Do not use local time without an offset (2026-03-08T20:00:00) in an API. It means different instants for different readers. If a time must be tied to a place (a shop opens at 09:00 in Paris), send the local time and a separate IANA zone name (Europe/Paris), since offsets change with daylight saving.

Money: whole numbers, with a currency

Floating-point numbers cannot store most decimals exactly. A tiny test:

TEXT
0.1 + 0.2 = 0.300000000000000041.005 rounded to 2 places: 11999 cents * 3 = 5997 -> 59.97

0.1 + 0.2 is not 0.3. And 1.005, which a person reads as "1 euro and half a cent, round up to 1.01", rounds to 1, because 1.005 is stored as 1.00499999999999989... If a price is a float, tax and discount calculations drift by a cent here and there, and you cannot reconcile the books.

The fix: send money as an integer in minor units, plus a currency code:

JavaScript
{ "balance": { "amount": 1999, "currency": "EUR" } }

That is 19.99 EUR. The currency code is ISO 4217 (EUR, USD, INR). Zalando's guidelines define a common "money object" with an amount and currency (theirs uses a decimal number written in a precise way and has more rules), and Stripe-style APIs use integer minor units. Both are fine. The important points: never a float, never a string like "19,99 €", and always the currency next to the amount. A few currencies have no minor unit (JPY) or three decimals (KWD), so say in the docs how the exponent is handled, or use a library.

Do not invent your own envelope for everything

A wrapper like {"ok":true,"data":{...}} repeats what HTTP already says (the status code). The only wrapper that is useful is the collection shape (items plus paging links, lesson 3). For single resources, return the resource itself. For errors, use the standard problem shape in Part 2.

Hide your database

The public shape is a contract. The database row is your private business. They should be written separately. Let me show what goes wrong when you return the row.

Part 2: What never to return, and what never to accept

Here is the "bad" server from this lesson. It returns whatever the database has and it inserts whatever the client sends. The database table has id, name, email, password_hash, role, created:

TEXT
$ curl localhost:4300/users/1{"id":1,"name":"Ada","email":"ada@example.com","password_hash":"$2b$10$abcdefghijklmnopqrstuv","role":"admin","created":"2026-03-08 14:30:00"}

Three problems in one line. The password_hash is exposed to anyone. The role is shown. The date 2026-03-08 14:30:00 has no zone, so nobody knows if it is UTC. And it is named created, not created_at.

Now creating a user. The bad handler does INSERT INTO users(<every key the client sent>):

TEXT
$ curl -H content-type:application/json -X POST localhost:4300/users \    -d '{"name":"Mallory","email":"m@evil.test","role":"admin"}'{"id":2,"name":"Mallory","email":"m@evil.test","password_hash":null,"role":"admin","created":null}

Mallory just made herself an admin by adding one field. This is mass assignment. The form on the website never had a role field, but an attacker does not use your form, they send JSON directly. (This is OWASP's Broken Access Control family, see Security Lesson 4.)

And the errors:

TEXT
$ curl -H content-type:application/json -X POST localhost:4300/users -d '{"name":"X","favourite":"blue"}'{"error":"table users has no column named favourite","stack":["Error: table users has no column named favourite","    at IncomingMessage.<anonymous> (file:///tmp/lab-api-2/bad.mjs:14:20)", ...$ curl -H content-type:application/json -X POST localhost:4300/users -d '{bad'{"error":"Expected property name or '}' in JSON at position 1 (line 1 column 2)","stack":["SyntaxError: ...

The response tells the world the table name, a file path and parts of the stack trace. An attacker learns your stack and your layout. And a client's bad JSON is a "500 server error" when it should be 400. (Status shown by the send(500, ...) in the code. The error is the client's.)

Fixing all of this is mostly three habits:

  1. Write the output shape by hand (a DTO / mapper). Never return row.
  2. Allow-list the input. Accept only known fields. Never copy unknown keys to the database.
  3. Never send internal error text. Log it on your side. Send a short message and a reference.

Here is the mapper from the good server:

JavaScript
// the PUBLIC shape. Written by hand, so a new column never leaks by accident.const toUser = r => ({  id: r.id, name: r.name, email: r.email, created_at: r.created_at,  balance: r.amount_minor == null ? null : { amount: r.amount_minor, currency: r.currency },});

And the output:

TEXT
$ curl localhost:4301/users/1{"id":1,"name":"Ada","email":"ada@example.com","created_at":"2026-03-08T14:30:00.000Z","balance":{"amount":1999,"currency":"EUR"}}

No password_hash, no role, a UTC date with a Z, and money in minor units with a currency. If someone adds a ssn column to the table next year, nothing leaks, because the mapper does not know about it. That is the point of having a separate public shape.

The classic leak is an ORM that returns whole rows, or res.json(user) on a model. Do not rely on "I will remember to delete the password field". Build the response from an allow-list of fields, so new columns are private by default.

Some teams add a type check in tests: a test that creates a user and asserts the response has exactly the documented keys.

Part 3: One error format, RFC 9457

Now the most valuable decision of this lesson. Every error from your API should have the same shape. Clients can then have one function that handles all failures, show the right message, and decide whether to retry.

You could design your own. Many companies have: Microsoft's guidelines use {"error": {"code": ..., "message": ..., "target": ..., "details": [...], "innererror": {...}}} with the code also copied into an x-ms-error-code header. Google's AIP-193 defines an error model with a status code, a message and typed details. Those are fine inside their own world.

But there is also a standard for plain HTTP APIs: RFC 9457, "Problem Details for HTTP APIs" (July 2023, it replaces RFC 7807). It defines a JSON object and a media type, application/problem+json. Both the Zalando and many other guidelines require it. The members, in plain words:

MemberMeaningExample
typeA URI that names the kind of problem. Default is about:blank, which means "nothing more than the HTTP status"https://api.example.com/problems/validation-error
titleA short, human summary of the kind of problem. The same for every case of this typeYour request has invalid fields
statusThe HTTP status code (a copy, to help tools that lose the status line)422
detailAn explanation of this occurrence3 problem(s) found. See errors.
instanceA URI that identifies this occurrence (a request id URL, or the resource path)/users/77

On top of these you can add extension members with your own data. The RFC's own example adds balance and accounts to an "out of credit" problem. Our server adds an errors list for field-level problems.

Here is the real output from the good server, for the same bad requests:

TEXT
$ curl -i -H content-type:application/json -X POST localhost:4301/users \    -d '{"name":"","email":"nope","colour":"blue"}'HTTP/1.1 422 Unprocessable Entitycontent-type: application/problem+json{"type":"https://api.example.com/problems/validation-error", "title":"Your request has invalid fields","status":422, "detail":"3 problem(s) found. See errors.", "errors":[{"pointer":"/colour","message":"unknown field"},           {"pointer":"/name","message":"required, a non-empty string"},           {"pointer":"/email","message":"required, must look like name@host.tld"}]}

(I added line breaks to make it fit; the server sends one line.) Look at what a client gets: a stable machine-readable type, a human title, the right HTTP status, and a list of every problem at once, each with a pointer to the field (a JSON Pointer such as /email). A form can now mark all three fields red in one go.

Other cases from the same server:

TEXT
$ curl -i ... -d '{"name":"Bob","email":"bob@example.com"}'    # second timeHTTP/1.1 409 Conflictcontent-type: application/problem+json{"type":"https://api.example.com/problems/email-taken","title":"Email already used","status":409,"detail":"A user with this email already exists."}$ curl -i ... -d '{bad'HTTP/1.1 400 Bad Requestcontent-type: application/problem+json{"type":"https://api.example.com/problems/malformed-json","title":"Malformed JSON","status":400,"detail":"The request body is not valid JSON."}$ curl -i localhost:4301/users/77HTTP/1.1 404 Not Foundcontent-type: application/problem+json{"type":"https://api.example.com/problems/not-found","title":"User not found","status":404,"detail":"No user with this id.","instance":"/users/77"}

Same shape for 409, 400 and 404. The mass-assignment attempt ("role":"admin") is now a 422 with {"pointer":"/role","message":"unknown field"}, and nothing is inserted.

Rules for good error design

  1. Right status code, same body shape, always. Even for 404 on an unknown route and 500 on a crash. A client never has to handle HTML from a crashed server. (Make a catch-all error handler. Lesson note: Backend Lesson 4 shows the central error handler in Express.)
  2. A stable type that the client can switch on. The title and detail are for humans and may change. The type (or an extension code) is the contract. Microsoft says the same: the top-level error code is part of the API contract and must not change, but the message is not. Document your types.
  3. Report all validation problems at once, not one per request.
  4. Say what to do. "Email already used. Log in or use a different email." is better than "conflict".
  5. Do not leak. No stack traces, SQL, file paths, library versions, or whether a username exists in a login error. For 500, our server sends a generic message and logs the real error on the server side. The detail says to quote a time, and a better version includes a request id that your logs also contain (a "correlation id", see Backend Lesson 5).
  6. Do not rely on the body for control flow that the status already handles. Rate limiting is 429 with a Retry-After header. The body only explains.
  7. Clients must not depend on detail text. It is for humans. (Zalando's note says clients must not depend on a problem object being present at all, for example when a proxy in the middle replies with its own error.)
  8. Make the media type work. Zalando's hint: some frameworks do not yet produce application/problem+json, and a client may need to send Accept: application/problem+json to get it. Test with the real client.

The type URI does not have to be a working web page, but it is much nicer when it is. If https://api.example.com/problems/email-taken opens a page that explains the error and how to fix it, you have written documentation that cannot go out of date. Zalando's text says these identifiers are not meant for the client to open automatically.

400 or 422 for validation?

We touched this in lesson 1. This server uses 400 for "I cannot parse it" (bad JSON), 415 for the wrong content type, 422 for "parsed, but the values break the rules", and 409 for a clash with existing data. Another team might use 400 for all validation. What matters is that the rule is written down and every route follows it.

Part 4: Validation that helps

Validation means checking input at the edge, before business logic runs. Good validation does four things:

  1. Checks the type and shape (name is a string, qty an integer).
  2. Checks the range and format (name is 1 to 80 characters, email looks like an email, qty is at least 1).
  3. Rejects unknown fields (or ignores them, on purpose). Our server rejects them, which also blocks mass assignment loudly. Some APIs prefer to ignore unknown fields to stay tolerant. Rejecting is stricter and surfaces client typos (colour instead of color) early. Ignoring is friendlier to old and new clients mixing. The only unsafe option is copying them into storage.
  4. Returns all the errors, with a pointer to each field.

Here is the whole validator from the good server. It is 10 lines. In a real project you will use a schema library (Zod, Valibot, Ajv with JSON Schema, or the validation from your framework), and ideally generate your OpenAPI document from the same schema (lesson 7):

JavaScript
const ALLOWED = ["name", "email"];function validate(d) {  const errors = [];  if (typeof d !== "object" || d === null || Array.isArray(d)) return [{ pointer: "", message: "body must be a JSON object" }];  for (const k of Object.keys(d)) if (!ALLOWED.includes(k)) errors.push({ pointer: "/" + k, message: "unknown field" });  if (typeof d.name !== "string" || d.name.trim() === "") errors.push({ pointer: "/name", message: "required, a non-empty string" });  else if (d.name.length > 80) errors.push({ pointer: "/name", message: "must be at most 80 characters" });  if (typeof d.email !== "string" || !/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(d.email)) errors.push({ pointer: "/email", message: "required, must look like name@host.tld" });  return errors;}

Two more points.

Validate output too (in tests). A response schema in a test catches the day someone adds a field that leaks. Lesson 7 shows how to check responses against an OpenAPI file.

Shape validation is not business validation. "The email looks right" is shape. "That email is already taken" is a rule that needs the database, and it is the 409 above. Also notice a trap there: checking "does it exist" and then inserting has a race. Two requests at once can both pass the check. The real protection is the UNIQUE constraint on the column. Our table has email TEXT NOT NULL UNIQUE. In production, catch the database's unique error and turn it into the same 409.

Also check size and content type

Limit the body size (a 2 GB JSON body is an easy denial-of-service), limit the string lengths, and require Content-Type: application/json for JSON bodies (return 415 if not). Our server returns 415 with a problem object if the header is wrong.

Part 5: A response design checklist

Before you ship an endpoint, check:

  • The top level is an object.
  • Names follow the one style. Dates end in _at and are RFC 3339 UTC. Booleans are never null. Arrays are never null.
  • Money is integer minor units with a currency. Ids that could pass 2^53 are strings.
  • The output is built by a mapper. No password hashes, tokens, internal ids, or flags like is_admin unless the caller should see them.
  • The input is checked against an allow-list. Unknown fields are rejected or ignored on purpose.
  • Every error is application/problem+json with the right status, a stable type, and field pointers for validation.
  • A 500 shows a generic message and a request id. Details go to the log.
  • 201 has Location, 405 has Allow, 429 has Retry-After.

Try it

  1. Take this response and list everything wrong with it:
    JavaScript
    [{"UserID": 17, "Name": "ada", "joined": "03/04/26", "balance": 12.5, "active": null, "tags": null}]
  2. Rewrite it so it follows this lesson. Assume the balance is 12.50 in dollars.
  3. Write the problem details JSON for POST /orders where qty is -3 and the item field is missing.
  4. Run bad.mjs and good.mjs (copy the code from this lesson and the lab folders) and send {"name":"Eve","email":"e@x.io","role":"admin"} to each. What happens in each?
  5. Find the stack trace risk in your own project: make a route throw on purpose and see what the client receives.

Answers

  1. The top level is an array (cannot grow). UserID and Name are PascalCase and mixed with other styles. joined has an ambiguous date with a two-digit year and no time zone, and its name does not show it is a date. balance is a float with no currency. active: null hides a third state. tags: null forces a null check.
  2. {"items":[{"id":"17","name":"ada","joined_at":"2026-04-03T00:00:00Z","balance":{"amount":1250,"currency":"USD"},"active":false,"tags":[]}]}. (I assumed 3 April 2026 for 03/04/26, and that it means a UTC midnight. You must ask the data owner, which is the point. The id as a string is optional if it never passes 2^53.)
  3. {"type":".../problems/validation-error","title":"Your request has invalid fields","status":422,"detail":"2 problem(s) found.","errors":[{"pointer":"/item","message":"required"},{"pointer":"/qty","message":"must be at least 1"}]}, with Content-Type: application/problem+json.
  4. bad.mjs creates a user whose role is admin (mass assignment). good.mjs returns 422 with {"pointer":"/role","message":"unknown field"} and creates nothing.
  5. Typical result: an HTML page or a JSON error with a stack trace. The fix is a catch-all error handler that logs the error and returns a generic problem object.

Interview questions

How do you format dates and times in an API? (Must know)

As RFC 3339 / ISO 8601 strings in UTC, for example 2026-03-08T14:30:00Z, and as 2026-03-08 for date-only values. The client converts to local time for display. I name the fields with an _at suffix so the type is clear. I avoid local times without an offset and I avoid bare Unix numbers because the unit is not obvious. If a time depends on a place, I send the local time plus an IANA zone name, since offsets change with daylight saving.

How do you represent money in a JSON API? Why not a float? (Must know)

As an integer in the smallest unit plus a currency code, like {"amount":1999,"currency":"EUR"}. Floats cannot store most decimals exactly: in JavaScript 0.1 + 0.2 is 0.30000000000000004, and 1.005 rounds to 1. Those errors add up in tax and totals. I put the currency next to the amount every time, and I handle currencies with zero or three decimals in a library.

What is RFC 9457 problem details? (Must know)

It is the standard JSON error format for HTTP APIs, with the media type application/problem+json. It has type (a URI naming the kind of problem), title, status, detail (about this occurrence) and instance, and you can add extension members such as an errors array. It replaced RFC 7807. The benefit is one error shape across the whole API, which clients can handle with one function, and a stable type they can switch on.

What should an error response never contain? (Must know)

Stack traces, SQL, table or file names, library versions, internal ids, and anything that tells an attacker whether an account exists. I log the details on the server with a request id, and send the client a short message and that id. In my test, the bad server leaked the table name and a file path in a 500 response, for what was really a client mistake.

What is mass assignment and how do you stop it? (Trap)

It is when the server copies all fields from the request into a database record, so a client can set fields it must not, like role or is_admin. In my lab, adding "role":"admin" to a sign-up request created an admin. I stop it with an allow-list of accepted fields (rejecting or ignoring the rest), separate input and output types, and never passing the request body straight to the ORM or to a SQL builder.

Should an API reject unknown fields or ignore them? (Senior signal)

Both are defensible, and the key is to choose on purpose and document it. Rejecting finds client typos early and blocks mass assignment loudly, but it makes it harder to roll out new fields in a mixed fleet of clients. Ignoring is more tolerant, in the spirit of "be liberal in what you accept", but silent typos can hide bugs. Either way, never copy unknown fields into storage.

null or a missing field? (Senior signal)

I treat them as meaning the same on responses, as Zalando suggests, so clients need one check. For PATCH merge patches, absent means "leave as is" and null means "clear it", and I document that. I never use null for booleans or for empty arrays: booleans are true or false and lists are [].

Why return ids as strings? (Senior signal)

JSON numbers are read by JavaScript as 64-bit floats, which are exact only up to 2^53 - 1. A larger numeric id is silently rounded in a browser. In my test the id 9007199254740993 came back as ...992. As a string it stays correct. It also leaves me free to change the id format later (numbers to UUIDs) without changing the type.

400, 422 or 409 for a bad request? (Must know)

400 when the request cannot be understood (broken JSON, wrong type). 422 when it is understood but the values break rules. 409 when it clashes with existing state, such as a duplicate email. Many teams use 400 for both parse and validation errors, which is fine if it is consistent and documented.

How do you design validation errors for a form? (Senior signal)

I return all problems in one response, as a list in an extension member of the problem object, each with a pointer to the field (like /email) and a message. The status is 422 or 400. The form can then highlight every bad field at once. I also keep the type stable so code can react to it, while the messages are free to change and be translated.

Why separate the database model from the API response model? (Must know)

They change for different reasons and have different audiences. The row has private things like password hashes and internal flags. If I return the row, a new column leaks by default. A hand-written mapper is an allow-list: only fields I chose to publish go out. It also lets me rename or reshape the table without breaking clients.

Where does validation happen: client, API, database? (Trap)

All three, for different reasons. The client validates for speed and comfort. The API validates because it can never trust a client: this is the real security check, with shape, ranges and allow-lists. The database enforces integrity that must hold under concurrency, such as UNIQUE and NOT NULL, because checks in code alone can race. The mistake is to rely only on the client.

Key takeaways

  • Return a JSON object at the top level and use one naming style everywhere.
  • Dates: RFC 3339 in UTC with a Z. Money: integer minor units plus a currency. Large ids: strings.
  • Never return the database row. Build the response with a mapper (an allow-list).
  • Never copy unknown input keys into the database. My lab server made an admin from one extra field.
  • One error format for everything: application/problem+json with a stable type, honest status, and field pointers in an errors extension.
  • Never leak stack traces, table names or paths. Log them, and send a request id.
  • Validation checks shape at the edge. The database enforces rules that must hold under race conditions.

What's next

Next: Pagination, Filtering and Sorting, Lesson 3. A list endpoint is the first one that breaks at scale. We load a table of hundreds of thousands of rows into SQLite and time OFFSET against a keyset cursor, then fix sorting, filtering and counts. Practise now: add a mapper and a validator to one endpoint of a project you know, and see what you find.

Published by Jainik · Last updated · How these lessons are made

Keep learning

Related lessons

Part of the study guide

Related series