{"id":535,"date":"2026-08-02T05:03:31","date_gmt":"2026-08-02T05:03:31","guid":{"rendered":"https:\/\/abrarqasim.com\/blog\/api-design-principles-the-four-i-enforce-in-code-review\/"},"modified":"2026-08-02T05:03:31","modified_gmt":"2026-08-02T05:03:31","slug":"api-design-principles-the-four-i-enforce-in-code-review","status":"publish","type":"post","link":"https:\/\/abrarqasim.com\/blog\/api-design-principles-the-four-i-enforce-in-code-review\/","title":{"rendered":"API Design Principles: The Four I Enforce in Code Review"},"content":{"rendered":"<p>Short version for the impatient: the four API design rules I actually enforce are stable error shapes, cursor pagination, idempotency keys on anything that charges money, and additive-only changes. Everything else I&rsquo;ve written down over the years turned out to be preference dressed up as principle.<\/p>\n<p>I know this because I spent a good chunk of last month reviewing an API a client&rsquo;s team had built, and I kept writing the same four comments. By the fourth PR I copy-pasted them. By the sixth I felt like a bot. So I wrote them down properly, with the reasoning, and now I paste a link instead.<\/p>\n<p>What follows is that document. It&rsquo;s opinionated, it&rsquo;s short, and I&rsquo;ve been wrong about at least one of these before.<\/p>\n<h2 id=\"rule-1-pick-an-error-shape-once-then-never-touch-it-again\">Rule 1: pick an error shape once, then never touch it again<\/h2>\n<p>The single worst thing about the API I was reviewing wasn&rsquo;t that the errors were bad. It&rsquo;s that there were four different kinds of bad. Validation failures came back as <code>{\"errors\": {\"email\": [\"is invalid\"]}}<\/code>. Auth failures came back as <code>{\"message\": \"unauthorized\"}<\/code>. The payment service returned <code>{\"error\": {\"code\": \"card_declined\", \"msg\": \"...\"}}<\/code>. And one endpoint, memorably, returned a 200 with <code>{\"success\": false}<\/code>.<\/p>\n<p>Every client that talked to this API had a little pile of if-statements to figure out which shape it just got. That pile is the actual cost. Not the ugliness.<\/p>\n<p>There&rsquo;s a standard for this and almost nobody uses it, which I find mildly infuriating. <a href=\"https:\/\/www.rfc-editor.org\/rfc\/rfc9457.html\" rel=\"nofollow noopener\" target=\"_blank\">RFC 9457, Problem Details for HTTP APIs<\/a>, was published in July 2023 and obsoletes RFC 7807 from 2016. It defines a JSON object with five members: <code>type<\/code>, <code>status<\/code>, <code>title<\/code>, <code>detail<\/code>, and <code>instance<\/code>. You can add your own fields on top.<\/p>\n<p>Here&rsquo;s what the old validation error looked like:<\/p>\n<pre><code class=\"language-json\">HTTP\/1.1 422 Unprocessable Content\nContent-Type: application\/json\n\n{\n  &quot;errors&quot;: {\n    &quot;email&quot;: [&quot;is invalid&quot;],\n    &quot;age&quot;: [&quot;must be a number&quot;]\n  }\n}\n<\/code><\/pre>\n<p>And here&rsquo;s the same failure as a problem document:<\/p>\n<pre><code class=\"language-json\">HTTP\/1.1 422 Unprocessable Content\nContent-Type: application\/problem+json\n\n{\n  &quot;type&quot;: &quot;https:\/\/api.example.com\/problems\/validation-error&quot;,\n  &quot;title&quot;: &quot;Your request is not valid.&quot;,\n  &quot;status&quot;: 422,\n  &quot;errors&quot;: [\n    { &quot;pointer&quot;: &quot;#\/email&quot;, &quot;detail&quot;: &quot;must be a valid address&quot; },\n    { &quot;pointer&quot;: &quot;#\/age&quot;,   &quot;detail&quot;: &quot;must be a positive integer&quot; }\n  ]\n}\n<\/code><\/pre>\n<p>The bit that earns its keep is <code>type<\/code>. It&rsquo;s a URI, it never changes, and clients switch on it instead of on the status code or on string-matching the message. The RFC is explicit that consumers should treat the type URI as the primary identifier and shouldn&rsquo;t parse <code>detail<\/code> for information. I&rsquo;ve watched two separate teams break clients by rewording an error message, and both times the fix was this.<\/p>\n<p>One practical warning from the security section of the RFC that I ignored for a while: don&rsquo;t put stack traces in <code>detail<\/code>. Problem details describe the HTTP interface, not your internals. I once shipped an API where a Postgres constraint name leaked into the error body, which told anyone paying attention exactly what my schema looked like.<\/p>\n<p>Implementing it in a Laravel app is about fifteen lines in the exception handler. In Go it&rsquo;s a struct and a helper. There&rsquo;s no framework work here, which is part of why the low adoption bugs me.<\/p>\n<h2 id=\"rule-2-offset-pagination-is-a-lie-your-database-tells-you\">Rule 2: offset pagination is a lie your database tells you<\/h2>\n<p>I shipped <code>?page=2&amp;per_page=50<\/code> for years. It&rsquo;s fine right up until the data moves under you, and then it silently duplicates and skips rows, and nobody notices because nobody tests pagination against a table that&rsquo;s being written to.<\/p>\n<p>Here&rsquo;s the version I used to write:<\/p>\n<pre><code class=\"language-php\">\/\/ Old: offset pagination\n$posts = Post::orderBy('created_at', 'desc')\n    -&gt;skip(($page - 1) * $perPage)\n    -&gt;take($perPage)\n    -&gt;get();\n<\/code><\/pre>\n<p>If three posts get inserted between the user&rsquo;s request for page 1 and page 2, three posts that were on page 1 shift onto page 2. The user sees them twice. If posts get deleted instead, rows slide the other way and vanish entirely. And on a large table, <code>OFFSET 100000<\/code> still makes Postgres walk 100,000 rows before discarding them.<\/p>\n<p>The cursor version:<\/p>\n<pre><code class=\"language-php\">\/\/ New: cursor pagination\n$query = Post::orderBy('created_at', 'desc')-&gt;orderBy('id', 'desc');\n\nif ($cursor = $request-&gt;query('page_token')) {\n    [$ts, $id] = decode_cursor($cursor);\n    $query-&gt;whereRaw('(created_at, id) &lt; (?, ?)', [$ts, $id]);\n}\n\n$posts = $query-&gt;take($perPage + 1)-&gt;get();\n$hasMore = $posts-&gt;count() &gt; $perPage;\n$posts = $posts-&gt;take($perPage);\n\nreturn [\n    'items' =&gt; $posts,\n    'next_page_token' =&gt; $hasMore ? encode_cursor($posts-&gt;last()) : null,\n];\n<\/code><\/pre>\n<p>Two details that took me embarrassingly long to get right. First, you need a tiebreaker column. Sorting by <code>created_at<\/code> alone breaks the moment two rows share a timestamp, which happens constantly in seeded data and bulk imports. The <code>(created_at, id)<\/code> row comparison handles it in one clause.<\/p>\n<p>Second, the token has to be opaque. <a href=\"https:\/\/google.aip.dev\/158\" rel=\"nofollow noopener\" target=\"_blank\">Google&rsquo;s AIP-158 on pagination<\/a> is blunt about this: page tokens must be opaque, URL-safe strings that users can&rsquo;t deconstruct, because the moment someone can parse your token, your pagination internals are part of your public API and you can never change them. I base64 a small signed payload. I&rsquo;ve also seen people encrypt it. Either beats handing out a raw timestamp and hoping.<\/p>\n<p>AIP-158 also suggests expiring tokens after a few days rather than storing them forever, which I didn&rsquo;t do and probably should.<\/p>\n<p>The trade-off is real, so I&rsquo;ll name it: cursors give up random page access. You can&rsquo;t jump to page 47. If your UI has numbered page buttons, offset is the honest choice and you should accept the drift. For infinite scroll and API consumers, cursors win every time. This is the same kind of decision I went through when I wrote up <a href=\"https:\/\/abrarqasim.com\/blog\/graphql-vs-rest-2026-when-each-one-actually-wins\" rel=\"noopener\">GraphQL vs REST in 2026<\/a> \u2014 the answer depends on the client, not on which one reads better in a blog post.<\/p>\n<h2 id=\"rule-3-idempotency-keys-on-anything-that-moves-money\">Rule 3: idempotency keys on anything that moves money<\/h2>\n<p>A user double-clicks a submit button. Their phone drops to 3G mid-request and the client retries. Your queue worker times out at 30 seconds, the job is re-queued, and the charge goes through twice.<\/p>\n<p>I&rsquo;ve been on the wrong end of this. It&rsquo;s a bad afternoon.<\/p>\n<p>The fix is an <code>Idempotency-Key<\/code> header: the client generates a UUID, sends it with the request, and the server remembers the response for that key. Same key twice, same response, one charge.<\/p>\n<pre><code class=\"language-go\">\/\/ Before: hope for the best\nfunc CreateCharge(w http.ResponseWriter, r *http.Request) {\n    var req ChargeRequest\n    json.NewDecoder(r.Body).Decode(&amp;req)\n    charge, err := billing.Charge(req.Amount, req.CustomerID)\n    \/\/ ... a retry here creates a second charge\n}\n<\/code><\/pre>\n<pre><code class=\"language-go\">\/\/ After: record the key before doing the work\nfunc CreateCharge(w http.ResponseWriter, r *http.Request) {\n    key := r.Header.Get(&quot;Idempotency-Key&quot;)\n    if key == &quot;&quot; {\n        problem.Write(w, 400, &quot;missing-idempotency-key&quot;)\n        return\n    }\n\n    if cached, found := store.Get(key); found {\n        w.WriteHeader(cached.Status)\n        w.Write(cached.Body)\n        return\n    }\n\n    \/\/ INSERT ... ON CONFLICT DO NOTHING \u2014 the DB is the lock\n    if !store.Claim(key, 24*time.Hour) {\n        problem.Write(w, 409, &quot;request-in-flight&quot;)\n        return\n    }\n\n    charge, err := billing.Charge(req.Amount, req.CustomerID)\n    store.Complete(key, 200, charge)\n    \/\/ ...\n}\n<\/code><\/pre>\n<p>The part people skip is <code>Claim<\/code>. Checking a cache and then doing the work is a race: two concurrent retries both miss the cache, both charge. You need an atomic claim, and a unique index with <code>ON CONFLICT DO NOTHING<\/code> is the cheapest one you already have.<\/p>\n<p>Now the honest bit. This header is not a standard. It&rsquo;s <a href=\"https:\/\/datatracker.ietf.org\/doc\/draft-ietf-httpapi-idempotency-key-header\/history\/\" rel=\"nofollow noopener\" target=\"_blank\">an IETF draft that&rsquo;s been in progress since 2020<\/a>, and the document history is a little sad: revision 07 was posted in October 2025, it expired in April 2026, and revisions 02, 04, 05 and 06 all expired before that too. Six years, seven revisions, still an expired draft.<\/p>\n<p>So why follow it? Because Stripe, Square and Adyen all use the same header name and roughly the same semantics, and matching what every payment provider already does is worth more than waiting for an RFC number. Every backend developer who touches your API will recognise it immediately.<\/p>\n<h2 id=\"rule-4-add-dont-change\">Rule 4: add, don&rsquo;t change<\/h2>\n<p>Versioning is the rule I spend the least time on and regret the least. My whole policy is: adding a field is free, removing or renaming one is a breaking change, and breaking changes need a new version.<\/p>\n<p>That&rsquo;s it. No <code>\/v2<\/code> unless something genuinely breaks. In practice I&rsquo;ve shipped one v2 in the last four years, and it was because we changed a resource&rsquo;s identity model, which is exactly the case that deserves it.<\/p>\n<p>The failure mode I do see constantly is clients that break on new fields. A strict JSON schema with <code>additionalProperties: false<\/code>, or a Go struct with <code>DisallowUnknownFields<\/code>, turns your additive change into their outage. Document that clients must ignore unknown fields, the same way RFC 9457 requires consumers to ignore extension members they don&rsquo;t recognise. Then when someone&rsquo;s parser blows up on a field you added, you have something to point at.<\/p>\n<p>The other thing I&rsquo;ve stopped doing is versioning in a custom header. <code>Accept: application\/vnd.example.v2+json<\/code> is technically more correct than <code>\/v2\/<\/code> in the path and I lost that argument three times before I concluded it doesn&rsquo;t matter. Put it in the path. It&rsquo;s greppable in logs, it&rsquo;s obvious in a browser, and nobody has ever been confused by it.<\/p>\n<h2 id=\"what-id-do-this-week\">What I&rsquo;d do this week<\/h2>\n<p>Pick one endpoint. Not the whole API, one endpoint that already returns errors.<\/p>\n<p>Convert its error responses to problem details, give the failure a <code>type<\/code> URI that resolves to a page on your docs site, and add a test that asserts the type URI rather than the message string. It takes an afternoon and it&rsquo;s the change with the best ratio of effort to future annoyance avoided.<\/p>\n<p>Then go look at your list endpoints and check whether any of them are paginating a table that gets written to while people are reading it. If yes, you have a duplicate-rows bug right now and you haven&rsquo;t heard about it because it looks like a UI glitch.<\/p>\n<p>I do a fair amount of API and backend work like this, and most of the <a href=\"https:\/\/abrarqasim.com\/work\" rel=\"noopener\">projects I&rsquo;ve shipped<\/a> came down to these same four decisions made early instead of late. None of them are clever. They&rsquo;re just the ones I&rsquo;ve paid for by not making.<\/p>\n","protected":false},"excerpt":{"rendered":"<p>The four API design rules I actually enforce in code review: RFC 9457 error shapes, cursor pagination, idempotency keys, and additive-only changes.<\/p>\n","protected":false},"author":2,"featured_media":534,"comment_status":"","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"rank_math_title":"","rank_math_description":"The four API design rules I actually enforce in code review: RFC 9457 error shapes, cursor pagination, idempotency keys, and additive-only changes.","rank_math_focus_keyword":"api design principles","rank_math_canonical_url":"","rank_math_robots":"","footnotes":""},"categories":[147,45],"tags":[417,602,49,600,601,591,599],"class_list":["post-535","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-backend","category-programming","tag-api-design-2","tag-api-versioning-2","tag-backend","tag-cursor-pagination","tag-idempotency-key","tag-rest-api-2","tag-rfc-9457-2"],"_links":{"self":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts\/535","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/users\/2"}],"replies":[{"embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/comments?post=535"}],"version-history":[{"count":0,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts\/535\/revisions"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/media\/534"}],"wp:attachment":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/media?parent=535"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/categories?post=535"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/tags?post=535"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}