Libraries & Ecosystem · کتابخانهها و اکوسیستم متوسطIntermediate ~44 دقیقه مطالعه~39 min read
طراحی API: REST، OpenAPI و نسخهبندیAPI Design: REST, OpenAPI & Versioning
از REST و کدهای وضعیت و idempotency تا نسخهبندی، فرمت خطای problem+json (RFC 9457)، طراحی قراردادمحور با OpenAPI، صفحهبندی cursor، rate limiting و انتخاب بین API key و OAuth — نقشهی کامل طراحی یک API حرفهای و ماندگار.From REST and status codes and idempotency to versioning, the problem+json error format (RFC 9457), contract-first design with OpenAPI, cursor pagination, rate limiting, and choosing between API keys and OAuth — a complete map for designing a professional, durable API.
سلام. بگذار همین اول یک واقعیت تلخ را بگویم: نوشتن کدی که «کار میکند» ساده است؛ طراحی یک API که سالها زنده بماند، هزاران مصرفکننده رویش حساب باز کنند و بتوانی بدون شکستن کسی تغییرش بدهی — این کار سخت است و مهارت واقعی سنیور همینجاست. در این فصل قرار نیست فقط یاد بگیری چطور یک @RestController بنویسی؛ قرار است بفهمی چرا هر تصمیم طراحی را میگیریم، کدام استانداردها امروز واقعاً معتبرند، و در مصاحبهی سنیور چطور دربارهی «قرارداد» بین سرور و کلاینت حرف بزنی.
با هم این مسیر را میرویم:
- مدل ذهنی بزرگ — چرا REST یک «سبک» است نه یک پروتکل، و API چیزی جز یک «قرارداد» نیست.
- بلوغ REST — مدل Richardson و چهار سطح آن.
- منابع، فعلها و کدهای وضعیت — الفبای HTTP که خیلیها اشتباه به کار میبرند.
- Idempotency — چرا این کلمهی سخت، جانِ APIهای پرداخت را نجات میدهد.
- صفحهبندی، فیلتر و مرتبسازی — offset در برابر cursor.
- فرمت خطا — استاندارد
problem+jsonو RFC 9457. - HATEOAS — بلندترین ادعای REST و واقعیت زمینیاش.
- نسخهبندی و سازگاری رو به عقب — چطور تغییر بدهی بیآنکه کسی را بشکنی.
- OpenAPI و طراحی قراردادمحور — قرارداد بهعنوان منبع حقیقت.
- Rate limiting و احراز هویت — API key در برابر OAuth.
- دامها، بهترین شیوهها و ۱۲ پرسش مصاحبه با پاسخ کامل.
بخش صفر — چند کلمه که پیش از شروع باید حسشان کنی
قبل از کد، چهار اصطلاح هست که در کل فصل برمیگردند. بگذار همین حالا با تشبیه جاشان بیندازم.
تصور کن به رستورانی میروی. تو (کلاینت) لازم نیست بدانی آشپزخانه چطور کار میکند، اجاق چند درجه است، یا گوشت را از کجا میخرند. تو فقط منو را نگاه میکنی: فهرستی از چیزهایی که میتوانی سفارش بدهی و اینکه هر کدام چه چیزی به تو تحویل میدهد. منو همان قرارداد (contract) است. تا وقتی رستوران به منو وفادار بماند، آشپزخانهاش را میتواند هر طور خواست عوض کند و تو متوجه نمیشوی. API دقیقاً همین است: یک قرارداد بین دو نرمافزار که یکی خدمت میدهد و دیگری مصرف میکند، مستقل از اینکه پشت پرده چه خبر است.
- منبع (resource): هر «چیز» قابلاشاره در سیستم تو — یک کاربر، یک سفارش، یک محصول. منبع یک مفهوم است، نه دادهی خام.
- بازنمایی (representation): شکلی که یک منبع در یک لحظه به خودش میگیرد؛ مثلاً همان کاربر بهصورت یک سند JSON. منبع «کاربر شماره ۷» یکی است، اما بازنماییاش میتواند JSON یا XML باشد.
- Idempotent (خنثیبهتکرار): عملی که اگر یک بار انجامش بدهی یا صد بار، نتیجهی نهایی روی سرور یکی است. خاموشکردن چراغ idempotent است؛ فشار «۱۰۰ بار» همان خاموش را میدهد. اما «یک قهوه سفارش بده» idempotent نیست: صد بار یعنی صد قهوه.
- قرارداد (contract): توافق دقیق بر سرِ اینکه چه درخواستی چه پاسخی میگیرد. تمام این فصل دربارهی محکم و پایدار نگهداشتن این قرارداد است.
مدل ذهنی: REST یک سبک است، نه یک پروتکل
یک پروتکل (مثل gRPC) مثل نقشهی دقیق یک خانه است: میگوید دیوار دقیقاً کجا باشد. اما REST مثل «قواعد شهرسازی» است: نمیگوید خانهات چه شکلی باشد، فقط چند اصل کلی میگذارد — «از خیابانهای موجود استفاده کن»، «آدرسها استاندارد باشند» — تا خانهات با بقیهی شهر جور دربیاید. REST مجموعهای از قیدهای معماری (architectural constraints) است که Roy Fielding در سال ۲۰۰۰ در رسالهی دکترایش تعریف کرد. اگر این قیدها را رعایت کنی، سیستمت خواصی مثل مقیاسپذیری، کششدن و تکامل مستقل به دست میآورد.
REST مخفف Representational State Transfer است. نکتهی کلیدی که خیلیها از دست میدهند این است: REST به تو نمیگوید از JSON استفاده کن، یا حتی از HTTP. REST یک سبک انتزاعی است. اما در عمل، تقریباً همیشه روی HTTP پیاده میشود، چون HTTP خودش بیشتر همان قیدها را دارد. مهمترین قیدها اینها هستند:
- کلاینت–سرور: مسئولیتها جدا؛ رابط کاربری از ذخیرهسازی داده مستقل تکامل مییابد.
- بیحالتی (stateless): هر درخواست باید همهچیزِ لازم برای فهمیدهشدن را با خودش بیاورد. سرور بین دو درخواست چیزی دربارهی کلاینت «به خاطر نمیسپارد».
- کشپذیری (cacheable): پاسخها باید بگویند آیا و تا کِی میشود کششان کرد.
- رابط یکنواخت (uniform interface): قلب REST — منابع با URI شناسایی میشوند و با مجموعهای استاندارد از فعلها دستکاری میشوند.
اگر سرور بین درخواستها چیزی به خاطر نسپارد، هر درخواست میتواند به هر نمونهای از سرورت برود. یعنی میتوانی افقی مقیاس بدهی: ده سرور بگذار پشت یک load balancer و کسی نمیفهمد کدام سرور به او خدمت داد. لحظهای که «حالت جلسه» را در حافظهی یک سرور نگه داری، آن کاربر به آن سرور میخکوب میشود و مقیاسپذیریات فرو میریزد. اینجاست که بیحالتی از یک اصل آکادمیک به یک تصمیم معماری میلیوندلاری تبدیل میشود.
بلوغ REST: مدل Richardson
خیلی از چیزهایی که مردم «REST API» صدا میزنند، در واقع نیمهREST هستند. Leonard Richardson یک مدل چهارسطحی ساخت تا بگوید یک API چقدر واقعاً از قابلیتهای وب استفاده میکند. این مدل معروفِ Richardson Maturity Model (RMM) است.
| سطح | نام | چه میکند | مشکل |
|---|---|---|---|
| ۰ | باتلاق POX | یک URI، یک فعل (معمولاً POST)، همهچیز درون بدنه | اصلاً از HTTP استفاده نمیکند؛ فقط تونل است |
| ۱ | منابع | چند URI برای چیزهای مختلف، اما هنوز یک فعل | فعلهای HTTP بیمعنیاند |
| ۲ | فعلهای HTTP | از GET/POST/PUT/DELETE و کدهای وضعیت درست استفاده میکند | «حالت بعدی» را کلاینت باید حدس بزند |
| ۳ | کنترلهای ابرمتنی (HATEOAS) | پاسخها لینکِ کارهای بعدی را هم میدهند | پیچیدگی بیشتر؛ کمتر رایج |
بیایید صادق باشیم: بیشتر APIهای تولیدی امروز در سطح ۲ میمانند و همانجا هم خیلی خوب کار میکنند. سطح ۳ (HATEOAS) از نظر Fielding «REST واقعی» است، اما در عمل هزینهاش را کمتر جایی توجیه میکند. هدف این نیست که کورکورانه به سطح ۳ برسی؛ هدف این است که آگاهانه بدانی کجا ایستادهای و چرا. در مصاحبه اگر بگویی «ما آگاهانه سطح ۲ ماندیم چون مصرفکنندههای ما لینکها را دنبال نمیکنند» این نشانهی بلوغ است، نه ضعف.
منابع، فعلها و کدهای وضعیت
منابع را با اسم نامگذاری کن، نه فعل
آدرس خانه میگوید «خیابان لاله، پلاک ۷» — یک اسم، یک مکان. آدرس نمیگوید «رنگکردن خانهی پلاک ۷». کارِ لازم را با ابزارت (فعل) مشخص میکنی، نه با آدرس. در REST هم URI باید یک منبع (اسم) را نامگذاری کند و فعلِ HTTP بگوید با آن چه میکنی. پس /getUser?id=7 غلط است (فعل داخل آدرس رفته)؛ درستش GET /users/7 است.
الگوی درست، منابع را بهصورت مجموعه و عضو مدل میکند:
GET /users ← فهرست کاربران (کالکشن)
POST /users ← ساختن کاربر تازه
GET /users/7 ← خواندن کاربر ۷
PUT /users/7 ← جایگزینی کامل کاربر ۷
PATCH /users/7 ← تغییر بخشی از کاربر ۷
DELETE /users/7 ← حذف کاربر ۷
GET /users/7/orders ← سفارشهای کاربر ۷ (منبع تودرتو)
فعلهای HTTP و دو خاصیت حیاتیشان
هر فعل HTTP دو خاصیت دارد که همهچیز حول آنها میچرخد: امن (safe) یعنی داده را تغییر نمیدهد، و idempotent یعنی تکرارش نتیجهی نهایی را عوض نمیکند.
| فعل | امن؟ | Idempotent؟ | کاربرد |
|---|---|---|---|
| GET | بله | بله | خواندن؛ هرگز نباید چیزی را تغییر دهد |
| HEAD | بله | بله | مثل GET اما فقط هدرها |
| PUT | خیر | بله | جایگزینی کامل؛ صد بار PUT = یک بار |
| DELETE | خیر | بله | حذف؛ بار دوم فقط 404 میدهد اما حالت یکی است |
| POST | خیر | خیر | ساختن؛ دو POST = دو منبع |
| PATCH | خیر | معمولاً خیر | تغییر جزئی؛ بستگی به طراحی دارد |
این تفکیک مستقیم از RFC 9110 (سند مرجع معنای HTTP که در ۲۰۲۲ منتشر شد و RFC 7231 را کنار زد) میآید.
خیلیها PUT و PATCH را یکی میگیرند. تفاوت اصولی این است: PUT جایگزینیِ کامل است؛ اگر فیلدی را در بدنه نفرستی، یعنی آن فیلد باید پاک/خالی شود. اما PATCH تغییر جزئی است؛ فقط فیلدهایی که فرستادی عوض میشوند. اگر PUT را مثل PATCH استفاده کنی (فقط فیلدهای تغییریافته را بفرستی)، کاربرانت داده از دست میدهند و ساعتها دنبال باگ میگردند.
کدهای وضعیت را درست انتخاب کن
کد وضعیت اولین چیزی است که کلاینت میخواند. اشتباهاستفاده از آن، یعنی همهی خطاها را ۲۰۰ برگرداندن و پیام خطا را در بدنه پنهانکردن، یکی از رایجترین ضدالگوهاست.
| کد | معنی | کِی؟ |
|---|---|---|
| 200 OK | موفق | GET/PUT/PATCH موفق |
| 201 Created | ساخته شد | POST که منبع تازه ساخت (هدر Location بده) |
| 202 Accepted | پذیرفته شد | کار async شروع شد، هنوز تمام نشده |
| 204 No Content | موفق، بدون بدنه | DELETE موفق |
| 400 Bad Request | درخواست خراب | JSON نامعتبر، فیلد غایب |
| 401 Unauthorized | احراز هویت نشدی | توکن نداری یا نامعتبر است |
| 403 Forbidden | اجازه نداری | احراز شدی اما مجوزش را نداری |
| 404 Not Found | پیدا نشد | منبع وجود ندارد |
| 409 Conflict | تعارض | نسخهی قدیمی، یا نقض یکتایی |
| 422 Unprocessable | معنایش غلط است | نحو درست اما اعتبارسنجی رد شد |
| 429 Too Many Requests | زیادی زدی | از rate limit رد شدی |
| 500 Internal Server Error | خطای سرور | باگ ماست، نه کلاینت |
| 503 Service Unavailable | موقتاً در دسترس نیست | overload یا نگهداری |
این دو را همه اشتباه میکنند. 401 یعنی «تو را نمیشناسم» — احراز هویت (authentication) شکست خورده؛ توکن نداری یا منقضی است. 403 یعنی «تو را میشناسم اما اجازه نداری» — مجوز (authorization) شکست خورده. جملهی طلایی برای مصاحبه: «401 دربارهی این است که کی هستی، 403 دربارهی این است که چه کاری مجازی.»
Idempotency: نجات API از طوفانِ تلاشمجدد
وقتی دکمهی آسانسور را میزنی و آسانسور دیر میآید، ناخودآگاه دوباره و دوباره میزنی. اما آسانسور فقط یک بار میآید؛ فشارهای اضافه بیاثرند. حالا تصور کن دکمه چنین نبود و هر فشار یک آسانسورِ جدا صدا میزد! این دقیقاً فاجعهای است که در APIها رخ میدهد: کلاینت درخواست پرداخت میفرستد، شبکه کند است، تایماوت میخورد، و کلاینت دوباره میفرستد — و حالا کاربر دو بار پول داده. Idempotency یعنی دکمهی آسانسورکردنِ APIها.
مشکل اینجاست: POST بهطور ذاتی idempotent نیست. اگر شبکه بین کلاینت و سرور قطع شود، کلاینت نمیداند آیا سرور درخواست را دریافت کرد یا نه، پس تلاش مجدد میکند. راهحل استاندارد صنعت (که Stripe رایجش کرد و حالا در پیشنویس IETF به نام draft-ietf-httpapi-idempotency-key-header در حال استانداردسازی است، نسخهی ۰۷ در اکتبر ۲۰۲۵) این است: کلاینت یک کلید یکتا (معمولاً UUID) در هدر Idempotency-Key میفرستد. سرور این کلید را ذخیره میکند و اگر همان کلید دوباره آمد، بهجای انجام دوبارهی کار، همان پاسخ قبلی را بازپخش میکند.
@RestController
@RequestMapping("/payments")
public class PaymentController {
private final PaymentService paymentService;
private final IdempotencyStore store; // مثلاً روی Redis
public PaymentController(PaymentService s, IdempotencyStore store) {
this.paymentService = s;
this.store = store;
}
@PostMapping
public ResponseEntity<PaymentResult> pay(
@RequestHeader("Idempotency-Key") String key,
@RequestBody PaymentRequest req) {
// اگر این کلید را قبلاً دیدهایم، همان نتیجه را بازپخش کن
var cached = store.find(key);
if (cached.isPresent()) {
return ResponseEntity.ok(cached.get());
}
// در غیر اینصورت کار را انجام بده و نتیجه را زیر همان کلید ذخیره کن
PaymentResult result = paymentService.charge(req);
store.save(key, result); // باید اتمیک باشد تا شرایط رقابتی رخ ندهد
return ResponseEntity.status(HttpStatus.CREATED).body(result);
}
}
دام ظریف: بین «چککردن کلید» و «ذخیرهی نتیجه» یک شرایط رقابتی (race condition) هست. اگر دو درخواست با یک کلید همزمان بیایند، هر دو ممکن است چک کنند، هیچکدام چیزی نبینند، و هر دو پرداخت را انجام دهند! راهحل درست: هنگام دریافت کلید، بلافاصله یک رکورد با وضعیت «در حال پردازش» بهصورت اتمیک درج کن (مثلاً INSERT ... ON CONFLICT DO NOTHING یا SETNX در Redis). درخواست دوم که کلید را «قفلشده» میبیند، یا منتظر میماند یا 409 میگیرد.
دقت کن که PUT و DELETE ذاتاً idempotentاند، پس برای آنها به Idempotency-Key نیاز نداری؛ تکرارشان بهخودیخود امن است. فقط POST (و گاهی PATCH) که «هر بار یک چیز تازه میسازند» به این محافظت نیاز دارند. این تفاوت را در مصاحبه بگویی، نشان میدهی که تفکیک safe/idempotent را واقعاً فهمیدهای، نه اینکه فقط حفظ کرده باشی.
صفحهبندی، فیلتر و مرتبسازی
هیچوقت یک کالکشن بیمرز برنگردان. اگر GET /orders یک میلیون سفارش را یکجا بریزد، هم سرورت خفه میشود هم کلاینت. باید صفحهبندی کنی. دو رویکرد اصلی هست.
offset در برابر cursor
صفحهبندی با offset مثل این است که بگویی «از صفحهی ۲۰ شروع کن، ۱۰ تا بده». دیتابیس مجبور است بیست صفحهی اول را بشمارد و رد کند تا به جای درست برسد — و اگر کتاب هنگام خواندنت ورق تازه اضافه شود، شمارهها به هم میریزند و ممکن است یک سطر را دو بار ببینی یا یکی را از دست بدهی. اما cursor مثل بوکمارک است: میگویی «از همانجا که آخرین بار بودم ادامه بده». دیتابیس مستقیم میپرد به آن نقطه، بدون شمردنِ ماقبلش، و اضافهشدنِ سطرهای جدید هم بوکمارک تو را جابهجا نمیکند.
| ویژگی | offset/limit | cursor (keyset) |
|---|---|---|
| نمونه | ?page=3&size=20 |
?limit=20&after=eyJpZCI6MTQwfQ |
| کارایی روی دادهی بزرگ | بد؛ OFFSET هرچه بزرگتر، کندتر |
عالی؛ مستقل از عمق صفحه |
| پرش به صفحهی دلخواه | آسان (صفحهی ۵۰!) | ناممکن؛ فقط بعدی/قبلی |
| پایداری هنگام درج/حذف | ناپایدار؛ ردیفها میلغزند | پایدار |
| شمارش کل صفحات | ساده | معمولاً نمیدهد |
منطق cursor روی این استوار است: بهجای «۲۰ تا را رد کن»، میگویی «همهی ردیفهایی که id آنها بزرگتر از آخرین id صفحهی قبل است را بده». چون روی ستون ایندکسشده پرش میکنی، سرعت ثابت میماند.
// cursor مبتنی بر keyset: صفحهی بعد را از روی id آخرین ردیف قبلی بگیر
@GetMapping("/orders")
public PageResponse<Order> list(
@RequestParam(required = false) Long after,
@RequestParam(defaultValue = "20") int limit) {
int capped = Math.min(limit, 100); // همیشه یک سقف بگذار!
List<Order> rows = (after == null)
? repo.findFirstPage(capped + 1) // یک تا بیشتر بگیر
: repo.findAfter(after, capped + 1); // تا بفهمی صفحهی بعد هست یا نه
boolean hasNext = rows.size() > capped;
if (hasNext) rows.remove(rows.size() - 1); // ردیف اضافه را دور بریز
Long nextCursor = hasNext ? rows.get(rows.size() - 1).id() : null;
return new PageResponse<>(rows, nextCursor);
}
دیدی که capped + 1 گرفتیم؟ این یک ترفند کلاسیک است: بهجای یک کوئریِ جدا برای اینکه بفهمی «آیا صفحهی بعدی وجود دارد»، فقط یک ردیف بیشتر میگیری. اگر تعداد برگشتی از سقف بیشتر بود، یعنی صفحهی بعدی هست؛ آن ردیف اضافه را دور میریزی. یک کوئریِ کمتر، بدونِ COUNT(*) گران.
فیلتر و مرتبسازی
فیلتر و مرتبسازی را با query parameter انجام بده، نه با ساختنِ endpointهای جدا:
GET /orders?status=SHIPPED&minTotal=100&sort=createdAt,desc
اگر sort=createdAt را بگیری و بیواسطه بچسبانی به ORDER BY، دری برای SQL injection باز کردهای. همیشه نام ستونهای مجاز را در یک allow-list سفید نگه دار و ورودی کاربر را با آن تطبیق بده. همینطور فیلترها را به یک زبان کوئریِ کنترلشده محدود کن؛ اجازه نده کاربر هر عبارت دلخواهی را اجرا کند.
فرمت خطا: استاندارد problem+json
بزرگترین بینظمیِ APIها در فرمت خطاست. هر تیم یک شکل اختراع میکند: یکی {"error": "..."}، یکی {"message": "...", "code": 42}، یکی متن خام. مصرفکننده باید برای هر API از نو یاد بگیرد. اینجاست که یک استاندارد نجاتت میدهد.
تصور کن هر شهر تابلوی «ایست» را به شکل و رنگ دلخواه خودش میساخت. رانندگی در شهر غریب کابوس میشد. بههمینخاطر تابلوها استاندارد شدهاند: هر جای دنیا، هشتضلعیِ قرمز یعنی ایست. problem+json همان استانداردسازی برای خطاهای API است: یک شکلِ ماشینخوان که هر کلاینتی از پیش میشناسدش.
استاندارد اصلی RFC 7807 بود (۲۰۱۶) که در RFC 9457 (منتشرشده در ژوئیه ۲۰۲۳) بازنگری و جایگزین شد. RFC 9457 همهی RFC 7807 را در بر دارد و چند بهبود میآورد (مثل پشتیبانی بهتر از «چند مشکل همزمان» و یک رجیستریِ مشترک برای انواع مشکل). Content-Type این پاسخها application/problem+json است. فیلدهای استاندارد:
type: یک URI که نوع مشکل را میشناساند (مثلاً لینک به مستندات آن خطا).title: خلاصهی کوتاه و انسانی از نوع مشکل.status: همان کد وضعیت HTTP، تکرارشده در بدنه.detail: توضیح انسانیِ مخصوص همین رخداد.instance: URI که خودِ این رخداد خاص را میشناساند.- بهعلاوه هر فیلد افزودهی دلخواه (مثلاً
errorsبرای جزئیات اعتبارسنجی).
خبر خوب برای جاواکارها: از Spring Boot 3.0 به بعد، این استاندارد بومی پشتیبانی میشود، از طریق کلاس ProblemDetail. کافی است یک پروپرتی روشن کنی یا ResponseEntityExceptionHandler را گسترش دهی.
// روش ۱: فعالکردن سراسری در application.properties
// spring.mvc.problemdetails.enabled=true
// روش ۲: مدیریت دستی خطا با ProblemDetail
@RestControllerAdvice
public class ApiExceptionHandler {
@ExceptionHandler(OrderNotFoundException.class)
public ProblemDetail handleNotFound(OrderNotFoundException ex) {
ProblemDetail pd = ProblemDetail.forStatusAndDetail(
HttpStatus.NOT_FOUND, ex.getMessage());
pd.setType(URI.create("https://api.example.com/errors/order-not-found"));
pd.setTitle("Order Not Found");
pd.setInstance(URI.create("/orders/" + ex.getOrderId()));
pd.setProperty("orderId", ex.getOrderId()); // فیلد افزوده
pd.setProperty("timestamp", Instant.now());
return pd; // Spring خودش Content-Type را application/problem+json میکند
}
}
پاسخِ تولیدشده چنین چیزی است:
{
"type": "https://api.example.com/errors/order-not-found",
"title": "Order Not Found",
"status": 404,
"detail": "No order exists with id 8801",
"instance": "/orders/8801",
"orderId": 8801,
"timestamp": "2026-07-20T10:15:30Z"
}
دام امنیتیِ کشنده: در detail هرگز پیام خام استثنا یا stack trace را قرار نده. جملهای مثل NullPointerException at com.example.PaymentService.line 88 به مهاجم نقشهی داخلی سیستمت را میدهد. برای خطاهای ۵۰۰، یک پیام عمومیِ امن بده و جزئیات را فقط در لاگ سمت سرور نگه دار (با یک traceId که هم در لاگ باشد هم در پاسخ، تا پشتیبانی بتواند دنبالش کند).
HATEOAS: بلندپروازانهترین بخش REST
HATEOAS مخفف Hypermedia As The Engine Of Application State است — سطح ۳ مدل Richardson.
وقتی در یک سایت میگردی، آدرس صفحهی بعد را حفظ نمیکنی؛ فقط روی لینکها کلیک میکنی. سایت خودش میگوید از اینجا کجا میتوانی بروی. HATEOAS همین ایده را به API میآورد: پاسخ هر منبع، علاوه بر داده، لینکِ کارهای ممکنِ بعدی را هم میدهد. کلاینت لازم نیست URLها را hardcode کند؛ فقط لینکهای داخل پاسخ را دنبال میکند.
مثلاً پاسخ یک سفارش، بسته به وضعیتش، لینکهای متفاوتی میدهد:
{
"id": 8801,
"status": "PENDING",
"total": 250.00,
"_links": {
"self": { "href": "/orders/8801" },
"cancel": { "href": "/orders/8801/cancel", "method": "POST" },
"pay": { "href": "/orders/8801/payment", "method": "POST" }
}
}
اگر سفارش قبلاً پرداخت شده بود، لینک pay دیگر نمیآمد. یعنی سرور به کلاینت میگوید «همین حالا چه کارهایی مجازند» و کلاینت مجبور نیست منطق وضعیت را در خودش تکرار کند. در Spring، کتابخانهی Spring HATEOAS با کلاس EntityModel و WebMvcLinkBuilder این لینکها را میسازد.
با اینکه از نظر تئوری زیباست، در عمل بیشتر تیمها HATEOAS را کامل پیاده نمیکنند، چون: (۱) کلاینتهای موبایل و SPA معمولاً URLها را از پیش میدانند و لینکها را دنبال نمیکنند، (۲) پیچیدگی و حجم پاسخ بالا میرود، (۳) ابزارهای تولید کد کمتر پشتیبانیاش میکنند. پس یک تصمیم آگاهانه است: اگر مصرفکنندههایت واقعاً از لینکها بهره میبرند (مثلاً یک workflow پیچیده با گذارِ وضعیت)، ارزشش را دارد؛ وگرنه سطح ۲ کافی است.
نسخهبندی و سازگاری رو به عقب
این بخش، جدیترین بخش سنیوریِ طراحی API است. لحظهای که اولین کلاینت به APIات وصل میشود، تو دیگر «صاحبِ آزادِ» قراردادت نیستی؛ در برابر آن کلاینت مسئولی.
وقتی خانهای را اجاره میدهی، دیگر نمیتوانی وسط قرارداد بگویی «از فردا اجاره دو برابر شد» یا «در ورودی را برداشتم». باید تا پایان قرارداد به شرایطش پایبند بمانی؛ تغییرات را برای دورهی بعد نگه داری. API هم همین است: کلاینتهایی که امروز به نسخهی فعلی وصلاند، حق دارند فردا هم همان رفتار را ببینند. تغییرِ شکننده را باید در یک «نسخهی جدید» بگذاری، نه اینکه زیر پای کلاینت فعلی را خالی کنی.
تغییرِ شکننده در برابر ناشکننده
اول باید بدانی چه چیزی کلاینت را میشکند:
- ناشکننده (backward-compatible): افزودن یک فیلد اختیاری جدید، افزودن یک endpoint جدید، افزودن یک مقدار جدید به یک enumِ خروجی (با احتیاط). کلاینت قدیمی اینها را نادیده میگیرد و به کارش ادامه میدهد.
- شکننده (breaking): حذف یا تغییر نام یک فیلد، تغییر نوع یک فیلد (رشته به عدد)، اجباریکردن یک فیلد ورودی که قبلاً اختیاری بود، تغییر معنای یک کد وضعیت، تنگترکردن قواعد اعتبارسنجی.
قانونی که باید در استخوانت باشد: فقط اضافه کن، هرگز حذف یا تغییر نده. به این «robustness principle» یا اصل قویبودن هم میگویند: در آنچه میفرستی سختگیر باش، در آنچه میپذیری بردبار. اگر کلاینتها فیلدهای ناشناخته را نادیده بگیرند (که باید بگیرند)، تو میتوانی سالها بدون شکستن کسی فیلد اضافه کنی. بیشترِ نیاز به «نسخهی جدید» وقتی پیش میآید که این اصل رعایت نشده باشد.
چهار استراتژی نسخهبندی
| استراتژی | نمونه | مزیت | عیب |
|---|---|---|---|
| URI path | /v2/users |
ساده، شفاف، کشپذیر، در مرورگر تستپذیر | «ناخالصِ REST»؛ URI منبع نباید تغییر کند |
| Query param | /users?version=2 |
ساده | کش و مسیریابی را پیچیده میکند |
| هدر سفارشی | X-API-Version: 2 |
URI تمیز میماند | نامرئی؛ در مرورگر تستناپذیر |
| Media type (content negotiation) | Accept: application/vnd.example.v2+json |
«خالصترین» از نظر REST | پیچیده، ناآشنا برای خیلیها |
در عمل، نسخهبندی روی URI path (مثل /v1/...) پرکاربردترین و عملیترین انتخاب است، چون شفاف است و همه میفهمندش. طرفدارانِ سرسختِ REST نسخهبندی با media type را «درستتر» میدانند، اما هزینهی عملیاتیاش بالاست.
اگر میخواهی از نسخهبندی URI استفاده کنی، v1 را در ابتدای مسیرِ کل API بگذار (/api/v1/users, /api/v1/orders)، نه اینکه هر منبع نسخهی جدا داشته باشد. مدیریتِ دهها منبع با نسخههای ناهماهنگ کابوس است. یک نسخه برای کل سطحِ API، ساده و قابلفهم است.
هر نسخهی جدید یعنی کدِ بیشتری که باید همزمان نگهداری کنی، تست کنی و امن نگه داری. هدف این نیست که برای هر تغییر کوچک /v2، /v3 بسازی؛ هدف این است که با اصلِ افزودنی، نیاز به نسخهی جدید را تا حد ممکن به تعویق بیندازی. وقتی مجبور به نسخهی جدید شدی، یک سیاست کنارگذاری (deprecation policy) روشن اعلام کن: چقدر نسخهی قدیمی زنده میماند، و با هدر Deprecation و Sunset به کلاینتها هشدار بده.
OpenAPI و طراحی قراردادمحور
حالا میرسیم به ابزاری که همهی اینها را به یک سند رسمی گره میزند: OpenAPI.
قبل از اینکه بنّا آجر بگذارد، معمار یک نقشه میکشد که همه — کارفرما، برقکار، لولهکش — از رویش کار میکنند. نقشه «منبع حقیقت» است. OpenAPI همان نقشه برای API است: یک فایل (YAML یا JSON) که دقیقاً میگوید چه endpointهایی هست، چه ورودیای میگیرند، چه خروجیای میدهند و چه خطاهایی ممکن است. از روی این یک فایل، هم مستندات تولید میشود، هم کدِ کلاینت، هم تست.
OpenAPI (که قبلاً Swagger نامیده میشد) استاندارد غالبِ توصیف REST APIهاست. نسخهها:
- OpenAPI 3.1 (فوریه ۲۰۲۱): نقطهعطف مهم — اولین نسخهای که کاملاً با JSON Schema (پیشنویس ۲۰۲۰-۱۲) همسو شد. یعنی همان schemaی که برای اعتبارسنجی داده به کار میبری، مستقیم در توصیف API قابلاستفاده است.
- OpenAPI 3.2 (سپتامبر ۲۰۲۵): تازهترین نسخه؛ افزودههایی مثل ناوبریِ ساختارمندِ tag، media typeهای مناسبِ streaming و flowهای تازهی OAuth روی همان پایهی همسو با JSON Schema.
دو سبک: code-first و contract-first
در code-first اول کد را مینویسی و بعد ابزار از روی کدت یک «عکس» (سند OpenAPI) میگیرد. سریع است، اما سند همیشه دنبالهروِ کد است. در contract-first اول «طرحِ» قرارداد (فایل OpenAPI) را میکشی، تیمها رویش توافق میکنند، و بعد کدِ سرور و کلاینت از روی آن ساخته میشود. کندتر شروع میشود اما هماهنگیِ بین تیمها را تضمین میکند.
در دنیای جاوا، کتابخانهی محبوب springdoc-openapi است که از annotationهای Spring، سند OpenAPI و رابط تعاملیِ Swagger UI را خودکار میسازد. نکتهی بهروز: از نسخهی 2.8.0 به بعد، springdoc بهصورت پیشفرض OpenAPI 3.1 تولید میکند.
// نمونهی code-first با springdoc-openapi
@RestController
@RequestMapping("/api/v1/orders")
@Tag(name = "Orders", description = "مدیریت سفارشها")
public class OrderController {
@Operation(summary = "خواندن یک سفارش با شناسه")
@ApiResponses({
@ApiResponse(responseCode = "200", description = "سفارش پیدا شد"),
@ApiResponse(responseCode = "404", description = "سفارش وجود ندارد",
content = @Content(mediaType = "application/problem+json"))
})
@GetMapping("/{id}")
public Order getOrder(
@Parameter(description = "شناسهی سفارش") @PathVariable Long id) {
return service.findById(id);
}
}
و همین که وابستگیِ springdoc-openapi-starter-webmvc-ui را اضافه کنی، مستنداتِ زنده در آدرس /swagger-ui.html بالا میآید و سند خام در /v3/api-docs.
اما در سبک contract-first، اول فایل OpenAPI را مینویسی:
openapi: 3.1.0
info:
title: Orders API
version: 1.0.0
paths:
/orders/{id}:
get:
operationId: getOrder
parameters:
- name: id
in: path
required: true
schema: { type: integer, format: int64 }
responses:
'200':
description: OK
content:
application/json:
schema: { $ref: '#/components/schemas/Order' }
'404':
description: Not Found
content:
application/problem+json:
schema: { $ref: '#/components/schemas/Problem' }
components:
schemas:
Order:
type: object
required: [id, status, total]
properties:
id: { type: integer, format: int64 }
status: { type: string, enum: [PENDING, PAID, SHIPPED] }
total: { type: number }
سپس با ابزاری مثل OpenAPI Generator از روی این فایل، هم اینترفیسهای سمت سرور (که کدت باید پیاده کند) و هم کلاینتهای آماده برای زبانهای مختلف تولید میشود.
وقتی تیمِ فرانت و بک همزمان باید کار کنند، contract-first یک قرارداد را «منجمد» میکند تا هر دو تیم موازی جلو بروند بیآنکه منتظر هم بمانند. سند OpenAPI منبعِ حقیقتِ مشترک میشود؛ اگر بک از قرارداد منحرف شد، تستهای قرارداد (contract testing) شکست میخورند و زود گیر میافتد. این یعنی جابهجاییِ باگهای یکپارچگی از «کشف در محیط تولید» به «کشف در زمان بیلد».
Rate limiting: محافظت از سرور در برابر سیل درخواست
اگر آب با فشار بینهایت وارد لوله شود، لوله میترکد. شیرِ فشارشکن جریان را به یک حدِ امن محدود میکند. rate limiting همین است: هیچ کلاینتی نباید بتواند با سیلی از درخواست، سرورت را زمین بزند (چه از سرِ بدخواهی، چه از باگِ یک حلقهی بیپایان در کدِ کلاینت). محدودیت را میگذاری تا برای همهی مصرفکنندهها انصاف و پایداری حفظ شود.
وقتی کلاینت از سهمیهاش رد شود، کد وضعیت درستْ 429 Too Many Requests است. اما فقط ردکردن کافی نیست؛ باید به کلاینت بگویی «چقدر مانده» و «کِی دوباره تلاش کن». برای این کار یک پیشنویس IETF به نام draft-ietf-httpapi-ratelimit-headers در حال استانداردسازیِ هدرهاست:
RateLimit: وضعیتِ فعلیِ سهمیه (چقدر مانده و کِی reset میشود).RateLimit-Policy: سیاستِ سهمیه که سرور اعلام میکند.- و در پاسخ 429، هدر استانداردِ
Retry-Afterکه میگوید چند ثانیه صبر کن.
@Component
public class RateLimitFilter extends OncePerRequestFilter {
private final RateLimiter limiter; // مثلاً token-bucket روی Redis
@Override
protected void doFilterInternal(HttpServletRequest req,
HttpServletResponse res, FilterChain chain)
throws ServletException, IOException {
String clientId = resolveClient(req); // بر اساس API key یا IP
Quota quota = limiter.tryConsume(clientId);
res.setHeader("RateLimit-Remaining", String.valueOf(quota.remaining()));
res.setHeader("RateLimit-Reset", String.valueOf(quota.resetSeconds()));
if (!quota.allowed()) {
res.setStatus(429);
res.setHeader("Retry-After", String.valueOf(quota.resetSeconds()));
res.setContentType("application/problem+json");
res.getWriter().write("""
{"type":"about:blank","title":"Too Many Requests","status":429}""");
return;
}
chain.doFilter(req, res);
}
}
رایجترین الگوریتم rate limiting «سطلِ توکن» است: هر کلاینت یک سطل با ظرفیت مشخص دارد که با نرخ ثابت توکن میگیرد؛ هر درخواست یک توکن مصرف میکند و اگر سطل خالی بود، 429 میگیرد. زیباییاش این است که هم یک نرخِ متوسط را محدود میکند و هم اجازهی «انفجارِ کوتاه» (burst) میدهد — چون سطل میتواند تا سقفِ ظرفیت پر شود. در سیستم توزیعشده، شمارنده را روی Redis نگه میداری تا همهی نمونههای سرور یک دید مشترک داشته باشند.
احراز هویت: API key در برابر OAuth
آخرین قطعهی طراحی، این است که «چه کسی» میتواند به APIات دست بزند. دو رویکرد رایج:
| ویژگی | API key | OAuth 2.0 / 2.1 |
|---|---|---|
| چیست | یک رشتهی رازِ ثابت | فریمورکِ تفویضِ دسترسی با توکن |
| هویتِ چه کسی | معمولاً یک اپلیکیشن/سرویس | کاربر یا اپلیکیشن، با دامنه (scope) |
| دسترسیِ ریزدانه | ندارد؛ همهیاهیچ | دارد؛ با scope و توکنِ کوتاهعمر |
| انقضا | معمولاً بیانتها تا لغو دستی | access token کوتاهعمر + refresh |
| کاربردِ خوب | ارتباطِ سروربهسرور، APIهای عمومیِ ساده | دسترسیِ کاربر، اپهای شخصثالث |
| ریسک | لو رفتنش یعنی دسترسیِ کامل و دائم | نشتِ توکن، محدود به عمرِ کوتاهش |
API key ساده و راحت است، اما ضعف دارد: چون یک رشتهی ثابت است، اگر در لاگ، در URL یا در گیتهاب لو برود، مهاجم دسترسیِ کامل و دائمی دارد. پس: (۱) هرگز API key را در query string نگذار (در لاگها میماند)؛ آن را در هدرِ Authorization یا یک هدرِ اختصاصی بگذار. (۲) قابلیتِ چرخش (rotation) و لغوِ فوری برایش بگذار. (۳) برای دسترسیِ کاربر یا شخصثالث، بهجایش سراغ OAuth برو.
نکتهی بهروز دربارهی OAuth: نسخهی OAuth 2.1 (که هنوز پیشنویس است اما در عمل پذیرفته شده — حتی پروتکل MCP شرکت Anthropic رویش بنا شده) چند تصمیمِ امنیتی را که پیشتر «توصیه» بودند، اجباری میکند:
- PKCE برای همهی کلاینتها اجباری شد (قبلاً فقط برای کلاینتهای عمومی توصیه میشد).
- flow ضمنی (implicit) بهکل حذف شد، چون توکن را مستقیم در مرورگر افشا میکرد.
- grant با نامکاربری/رمز (ROPC) منسوخ شد.
- توکن هرگز نباید در query string برود؛ فقط در هدرِ
Authorization. - تطبیقِ دقیقِ redirect URI (بدون wildcard).
تفکیکی که همه باید بلد باشند: احراز هویت (authentication) یعنی «تو کی هستی» و مجوزدهی (authorization) یعنی «چه کاری مجازی». API key بیشتر به سؤال اول جواب میدهد (کدام اپ)، در حالیکه OAuth با scopeها هر دو را میپوشاند و مهمتر، با توکنِ کوتاهعمر «شعاعِ انفجارِ» یک نشتی را کوچک میکند. انتخاب بین این دو، نه سلیقه، بلکه تابعِ «چه کسی مصرفکننده است و چقدر دسترسیِ ریزدانه میخواهی» است.
دامهای رایج
بیایید مهمترین تلهها را یکجا جمع کنیم — همانهایی که در مصاحبه یا کدِ واقعی گاز میگیرند:
- فعل در URI:
/getUser,/createOrder— منبع را با اسم نامگذاری کن و فعل را به HTTP بسپار. - همهچیز 200: خطاها را با کدِ 200 و پیام در بدنه برنگردان؛ کدِ وضعیتِ درست بده.
- کالکشنِ بیمرز: بدونِ صفحهبندی، یک
GET /ordersمیتواند سرورت را زمین بزند. همیشه سقف بگذار. - POST بدونِ idempotency: در APIهای پول/سفارش، نبودِ
Idempotency-Keyیعنی دوبارهپرداخت هنگام تلاشمجدد. - تغییرِ شکننده بدونِ نسخه: حذف یا تغییرِ نامِ فیلد در نسخهی موجود، کلاینتهای تولیدی را میشکند.
- افشای جزئیاتِ داخلی در خطا: stack trace در پاسخ = نقشهی حمله برای مهاجم.
sortمستقیم به SQL: دری برای SQL injection؛ همیشه allow-list.- API key در query string: در لاگها و تاریخچهی مرورگر میماند؛ در هدر بگذار.
بهترین شیوهها
- منابع را با اسمِ جمع مدل کن (
/users، نه/userیا/getUser) و فعلهای HTTP را درست به کار ببر. - کدهای وضعیت را دقیق انتخاب کن؛ بهویژه 201 با هدر
Location، و تفکیکِ 401/403 و 400/422. - خطاها را با
application/problem+json(RFC 9457) استاندارد کن؛ در Spring Boot 3 ازProblemDetailاستفاده کن. - برای کالکشنها cursor pagination را ترجیح بده، همیشه با یک
limitسقفدار. - با اصلِ افزودنی، نه کاهنده تکامل بده تا نیاز به نسخهی جدید کم شود؛ نسخه را روی مسیرِ کل API بگذار.
- API را با OpenAPI مستند کن؛ برای همکاریِ چندتیمی contract-first را در نظر بگیر.
- rate limiting و احرازِ درست (API key برای سروربهسرور، OAuth 2.1 برای کاربر) را از روزِ اول بگذار.
پرسشهای مصاحبه
حالا وقتش است همهچیز را با پرسشهای واقعیِ مصاحبهی سنیور محک بزنیم. اول خودت جواب بده، بعد پاسخ را باز کن.
safe یعنی فعل هیچ حالتی را روی سرور تغییر نمیدهد (مثل GET). idempotent یعنی تکرارِ فعل نتیجهی نهایی را عوض نمیکند. این دو مستقلاند: DELETE ناامن است (حالت را تغییر میدهد) اما idempotent است (حذفِ یک منبع، بار دوم فقط 404 میدهد اما حالتِ نهایی — نبودِ منبع — همان است). PUT هم همینطور. POST هیچکدام نیست. این تفکیک از RFC 9110 میآید.
چون POST ذاتاً idempotent نیست: هر بار یک منبعِ تازه میسازد، پس تلاشِ مجددِ ناشی از تایماوتِ شبکه میتواند دو منبع (دو پرداخت) بسازد. Idempotency-Key یک کلیدِ یکتا از کلاینت است که سرور با آن، تلاشهای مجدد را تشخیص میدهد و همان پاسخِ قبلی را بازپخش میکند. اما PUT و DELETE ذاتاً idempotentاند، پس تکرارشان بهخودیخود امن است و به این کلید نیاز ندارند.
401 Unauthorized یعنی احراز هویت شکست خورده — سرور نمیداند تو کی هستی (توکن نداری، نامعتبر یا منقضی است). 403 Forbidden یعنی احراز موفق بوده اما مجوزِ این کارِ خاص را نداری — سرور میداند تو کی هستی، ولی اجازه نداری. خلاصه: 401 دربارهی هویت، 403 دربارهی مجوز.
PUT جایگزینیِ کامل منبع است: بدنه باید کلِ منبع را داشته باشد و فیلدهای غایب یعنی «خالی/پیشفرض». PATCH تغییرِ جزئی است: فقط فیلدهای فرستادهشده عوض میشوند. دامِ کلاسیک این است که کسی PUT را مثل PATCH استفاده کند (فقط فیلدهای تغییریافته را بفرستد)؛ نتیجه اینکه بقیهی فیلدها پاک میشوند و کاربر داده از دست میدهد.
offset (مثل page=3&size=20) ساده است و پرش به هر صفحه را ممکن میکند، اما روی دادهی بزرگ کند میشود (دیتابیس باید همهی ردیفهای قبل را رد کند) و هنگام درج/حذف ناپایدار است (ردیفها میلغزند). cursor/keyset (مثل after=<id>) روی ستونِ ایندکسشده پرش میکند، پس سرعتش مستقل از عمقِ صفحه است و در برابر درج/حذف پایدار است؛ اما پرش به صفحهی دلخواه ندارد. برای فیدهای بینهایت و دادهی بزرگ cursor؛ برای UIهای «صفحهی ۵ از ۱۰» که داده کوچک است offset.
استاندارد RFC 9457 (جانشینِ RFC 7807، منتشر ۲۰۲۳) با media typeِ application/problem+json است؛ فیلدهای type, title, status, detail, instance بهعلاوهی فیلدهای افزوده. از Spring Boot 3 به بعد این استاندارد بومی پشتیبانی میشود: یا spring.mvc.problemdetails.enabled=true را روشن میکنی، یا ResponseEntityExceptionHandler را گسترش میدهی، یا در @RestControllerAdvice یک ProblemDetail برمیگردانی.
شکننده: حذف/تغییرِ نامِ فیلد، تغییرِ نوع، اجباریکردنِ فیلدِ ورودیِ قبلاً اختیاری، تغییرِ معنای کدِ وضعیت. ناشکننده: افزودنِ فیلدِ اختیاری یا endpointِ جدید. اصلِ طلایی: فقط اضافه کن، حذف/تغییر نده (robustness principle: در فرستادن سختگیر، در پذیرفتن بردبار). اگر کلاینتها فیلدهای ناشناخته را نادیده بگیرند، میتوانی سالها بدونِ نسخهی جدید فیلد بیفزایی.
چهار راه: URI path (/v1/)، query param (?version=1)، هدرِ سفارشی (X-API-Version)، و media type (Accept: application/vnd.x.v1+json). طرفدارانِ سرسختِ REST، media type را «درستترین» میدانند چون URIِ منبع را ثابت نگه میدارد. اما در عمل URI path پرکاربردترین است چون شفاف، کشپذیر و در مرورگر تستپذیر است. نسخه را روی مسیرِ کل API بگذار، نه تکتک منابع، و یک deprecation policyِ روشن اعلام کن.
OpenAPI (سابقاً Swagger) استانداردِ توصیفِ REST APIهاست؛ نسخهی 3.1 با JSON Schema 2020-12 کاملاً همسو شد و 3.2 (۲۰۲۵) تازهترین است. در code-first اول کد را مینویسی و ابزار (مثل springdoc-openapi) سند را از آن استخراج میکند. در contract-first اول فایلِ OpenAPI را مینویسی، تیمها رویش توافق میکنند و کدِ سرور/کلاینت از آن تولید میشود؛ برای همکاریِ چندتیمی و contract testing برتری دارد چون یک «منبعِ حقیقتِ» مشترک میسازد.
HATEOAS یعنی پاسخِ هر منبع، علاوه بر داده، لینکِ کارهای بعدیِ ممکن را هم بدهد، تا کلاینت بهجای hardcodeکردنِ URLها، لینکها را دنبال کند — بالاترین سطحِ بلوغِ Richardson (سطح ۳) و از نظر Fielding «REST واقعی». کمتر استفاده میشود چون کلاینتهای موبایل/SPA معمولاً URLها را از پیش میدانند، حجم و پیچیدگیِ پاسخ بالا میرود و ابزارهای تولیدِ کد کمتر پشتیبانی میکنند. بیشتر APIها آگاهانه در سطح ۲ میمانند.
API key یک رازِ ثابت است؛ ساده، خوب برای سروربهسرور و APIهای عمومیِ ساده، اما همهیاهیچ و بدونِ انقضا — لو رفتنش یعنی دسترسیِ کاملِ دائم. OAuth 2.0/2.1 فریمورکِ تفویضِ دسترسی با توکنِ کوتاهعمر و scope است؛ برای دسترسیِ کاربر و اپهای شخصثالث، و ریسکِ نشتِ توکن به عمرِ کوتاهش محدود میشود. OAuth 2.1 حالا PKCE را برای همه اجباری کرده، flowِ implicit را حذف کرده و توکندرِquery را ممنوع کرده. API key را هرگز در query string نگذار.
کلاینت یک Idempotency-Key یکتا (UUID) در هدر میفرستد. سرور هنگام دریافت، بهصورت اتمیک یک رکورد با وضعیتِ «در حال پردازش» زیرِ آن کلید درج میکند (INSERT ... ON CONFLICT DO NOTHING یا SETNX). اگر درج موفق شد، پرداخت را انجام میدهد و نتیجه را زیرِ همان کلید ذخیره میکند و 201 میدهد. اگر کلید از قبل بود: در صورت تمامشدنِ کارِ قبلی، همان نتیجهی ذخیرهشده را بازپخش میکند؛ در صورتِ درحالپردازشبودن، 409 (یا منتظر میماند). نکتهی حیاتی: درجِ اتمیک است که شرایطِ رقابتیِ دو درخواستِ همزمان را میبندد — صرفِ «چککنبعدذخیرهکن» کافی نیست.
- API یک قرارداد است، نه فقط کد؛ REST یک سبکِ معماری (نه پروتکل) با قیدهایی مثل بیحالتی و رابطِ یکنواخت است.
- مدلِ Richardson چهار سطح دارد؛ بیشترِ APIها آگاهانه در سطح ۲ (فعلها و کدهای وضعیتِ درست) میمانند و سطح ۳ (HATEOAS) کمکاربردتر است.
- منابع را با اسم نامگذاری کن، فعلها را درست به کار ببر، و کدهای وضعیت (بهویژه 401/403 و 400/422 و 201) را دقیق بده.
- idempotency با
Idempotency-Keyجانِ POSTهای پرداخت را نجات میدهد؛ فعلهای idempotent (PUT/DELETE) به آن نیاز ندارند. حواست به شرایطِ رقابتی باشد. - برای کالکشن cursor pagination را ترجیح بده؛ فیلتر/مرتبسازی با query param و allow-list.
- خطاها را با
problem+json(RFC 9457) استاندارد کن؛ Spring Boot 3 باProblemDetailبومی پشتیبانی میکند. جزئیاتِ داخلی را لو نده. - با اصلِ افزودنی، نه کاهنده تکامل بده؛ نسخه را روی مسیرِ API بگذار و deprecation policy اعلام کن.
- OpenAPI 3.1/3.2 قراردادت را رسمی میکند؛ contract-first برای تیمهای بزرگ برنده است (springdoc در جاوا).
- rate limiting (429 + هدرهای
RateLimit) و احرازِ درست (API key برای سروربهسرور، OAuth 2.1 برای کاربر) را از روزِ اول بگذار.
Let me open with an uncomfortable truth: writing code that "works" is easy; designing an API that lives for years, that thousands of consumers depend on, and that you can keep changing without breaking anyone — that is hard, and it is exactly where real senior skill shows. In this chapter you won't just learn how to write a @RestController; you'll understand why we make each design decision, which standards are genuinely current today, and how to talk about the "contract" between server and client in a senior interview.
Here's the path we'll walk together:
- The big mental model — why REST is a style, not a protocol, and why an API is nothing more than a contract.
- REST maturity — Richardson's model and its four levels.
- Resources, verbs, and status codes — the HTTP alphabet that so many people misuse.
- Idempotency — why this hard word saves the life of payment APIs.
- Pagination, filtering, and sorting — offset versus cursor.
- Error format — the
problem+jsonstandard and RFC 9457. - HATEOAS — REST's boldest claim and its earthly reality.
- Versioning and backward compatibility — how to change without breaking anyone.
- OpenAPI and contract-first design — the contract as the source of truth.
- Rate limiting and authentication — API keys versus OAuth.
- Pitfalls, best practices, and 12 interview questions with full answers.
Part 0 — a few words you must feel before we start
Before any code, four terms recur throughout this chapter. Let me plant them with analogies now.
Imagine you walk into a restaurant. You (the client) don't need to know how the kitchen works, what temperature the oven runs at, or where they buy the meat. You just read the menu: a list of things you can order and what each one delivers. The menu is the contract. As long as the restaurant honors the menu, it can change its kitchen however it likes and you never notice. An API is exactly that: a contract between two pieces of software — one serving, one consuming — independent of whatever happens behind the curtain.
- Resource: any addressable "thing" in your system — a user, an order, a product. A resource is a concept, not raw data.
- Representation: the shape a resource takes at a moment in time — say, that user as a JSON document. The resource "user 7" is one thing, but its representation can be JSON or XML.
- Idempotent: an operation whose final effect on the server is the same whether you do it once or a hundred times. Turning off a light is idempotent; pressing "off" 100 times still yields off. But "order a coffee" is not idempotent: a hundred times means a hundred coffees.
- Contract: the precise agreement about which request yields which response. This whole chapter is about keeping that contract solid and durable.
Mental model: REST is a style, not a protocol
A protocol (like gRPC) is like the exact blueprint of a house: it says precisely where each wall goes. But REST is like zoning rules: it doesn't tell you what your house looks like, it just lays down a few principles — "use the existing streets," "addresses must be standard" — so your house fits into the rest of the city. REST is a set of architectural constraints that Roy Fielding defined in his 2000 doctoral dissertation. Honor those constraints and your system gains properties like scalability, cacheability, and independent evolution.
REST stands for Representational State Transfer. The key point most people miss: REST does not tell you to use JSON, or even HTTP. REST is an abstract style. But in practice it's nearly always implemented over HTTP, because HTTP itself already embodies most of the same constraints. The most important ones are:
- Client–server: responsibilities are split; the UI evolves independently of data storage.
- Statelessness: each request must carry everything needed to understand it. The server "remembers" nothing about the client between requests.
- Cacheability: responses must declare whether and for how long they can be cached.
- Uniform interface: the heart of REST — resources are identified by URIs and manipulated through a standard set of verbs.
If the server remembers nothing between requests, any request can go to any instance of your server. That means you can scale horizontally: put ten servers behind a load balancer and nobody cares which one served them. The moment you keep "session state" in one server's memory, that user is pinned to that server and your scalability collapses. This is where statelessness graduates from an academic principle to a million-dollar architectural decision.
REST maturity: the Richardson model
Much of what people call a "REST API" is really half-REST. Leonard Richardson built a four-level model to describe how fully an API actually uses the web's capabilities. It's the famous Richardson Maturity Model (RMM).
| Level | Name | What it does | The problem |
|---|---|---|---|
| 0 | The Swamp of POX | One URI, one verb (usually POST), everything in the body | Doesn't use HTTP at all; it's just a tunnel |
| 1 | Resources | Many URIs for different things, but still one verb | HTTP verbs are meaningless |
| 2 | HTTP verbs | Uses GET/POST/PUT/DELETE and correct status codes | The "next state" must be guessed by the client |
| 3 | Hypermedia controls (HATEOAS) | Responses also give links to next actions | More complexity; less common |
Let's be honest: most production APIs today stop at Level 2, and they work very well right there. Level 3 (HATEOAS) is, per Fielding, "true REST," but in practice it rarely justifies its cost. The goal is not to blindly climb to Level 3; the goal is to consciously know where you stand and why. In an interview, saying "we deliberately stayed at Level 2 because our consumers don't follow links" is a sign of maturity, not weakness.
Resources, verbs, and status codes
Name resources with nouns, not verbs
A house address says "17 Maple Street" — a noun, a place. It doesn't say "painting-the-house-at-17-Maple." You specify the work with your tool (the verb), not with the address. In REST, a URI should name a resource (a noun), and the HTTP verb says what you do with it. So /getUser?id=7 is wrong (the verb crept into the address); the right form is GET /users/7.
The correct pattern models resources as collections and members:
GET /users ← list users (a collection)
POST /users ← create a new user
GET /users/7 ← read user 7
PUT /users/7 ← full replacement of user 7
PATCH /users/7 ← partial change to user 7
DELETE /users/7 ← delete user 7
GET /users/7/orders ← orders of user 7 (nested resource)
HTTP verbs and their two vital properties
Every HTTP verb has two properties that everything revolves around: safe means it doesn't change data, and idempotent means repeating it doesn't change the final effect.
| Verb | Safe? | Idempotent? | Use |
|---|---|---|---|
| GET | Yes | Yes | Read; must never change anything |
| HEAD | Yes | Yes | Like GET but headers only |
| PUT | No | Yes | Full replacement; 100 PUTs = 1 |
| DELETE | No | Yes | Delete; the 2nd call just 404s but the state is the same |
| POST | No | No | Create; two POSTs = two resources |
| PATCH | No | Usually no | Partial change; depends on design |
This distinction comes straight from RFC 9110 (the HTTP Semantics reference, published in 2022, obsoleting RFC 7231).
Many people treat PUT and PATCH as the same. The fundamental difference: PUT is a full replacement; if you omit a field in the body, that means the field should be cleared/emptied. But PATCH is a partial change; only the fields you send are modified. If you use PUT like PATCH (sending only changed fields), your users lose data and spend hours hunting a bug.
Choose status codes correctly
The status code is the first thing a client reads. Misusing it — returning everything as 200 and hiding the error message in the body — is one of the most common anti-patterns.
| Code | Meaning | When? |
|---|---|---|
| 200 OK | Success | Successful GET/PUT/PATCH |
| 201 Created | Created | POST that made a new resource (send a Location header) |
| 202 Accepted | Accepted | Async work started, not finished yet |
| 204 No Content | Success, no body | Successful DELETE |
| 400 Bad Request | Malformed request | Invalid JSON, missing field |
| 401 Unauthorized | Not authenticated | No token, or invalid |
| 403 Forbidden | Not permitted | Authenticated but lacks permission |
| 404 Not Found | Not found | Resource doesn't exist |
| 409 Conflict | Conflict | Stale version, or uniqueness violation |
| 422 Unprocessable | Semantically wrong | Syntax fine but validation failed |
| 429 Too Many Requests | You hit the limit | Exceeded rate limit |
| 500 Internal Server Error | Server error | Our bug, not the client's |
| 503 Service Unavailable | Temporarily unavailable | Overload or maintenance |
Everyone gets these two wrong. 401 means "I don't know who you are" — authentication failed; you have no token or it's expired. 403 means "I know who you are but you're not allowed" — authorization failed. The golden interview line: "401 is about who you are, 403 is about what you're allowed to do."
Idempotency: saving the API from retry storms
When you press the elevator button and it's slow to arrive, you instinctively press it again and again. But the elevator comes only once; the extra presses have no effect. Now imagine the button wasn't like that and every press summoned a separate elevator! That's exactly the catastrophe that happens in APIs: the client sends a payment request, the network is slow, it times out, and the client sends it again — and now the user has paid twice. Idempotency is making your API behave like that elevator button.
Here's the problem: POST is inherently not idempotent. If the network between client and server drops, the client doesn't know whether the server received the request, so it retries. The industry-standard solution (popularized by Stripe, and now being standardized as the IETF draft draft-ietf-httpapi-idempotency-key-header, version 07 as of October 2025) is this: the client sends a unique key (usually a UUID) in an Idempotency-Key header. The server stores this key, and if the same key arrives again, instead of doing the work again it replays the previous response.
@RestController
@RequestMapping("/payments")
public class PaymentController {
private final PaymentService paymentService;
private final IdempotencyStore store; // e.g. backed by Redis
public PaymentController(PaymentService s, IdempotencyStore store) {
this.paymentService = s;
this.store = store;
}
@PostMapping
public ResponseEntity<PaymentResult> pay(
@RequestHeader("Idempotency-Key") String key,
@RequestBody PaymentRequest req) {
// If we've seen this key before, replay the same result
var cached = store.find(key);
if (cached.isPresent()) {
return ResponseEntity.ok(cached.get());
}
// Otherwise do the work and store the result under this key
PaymentResult result = paymentService.charge(req);
store.save(key, result); // must be atomic to avoid a race condition
return ResponseEntity.status(HttpStatus.CREATED).body(result);
}
}
The subtle trap: between "check the key" and "store the result" there's a race condition. If two requests with the same key arrive concurrently, both may check, both see nothing, and both perform the payment! The correct fix: on receiving the key, immediately insert a record with status "in progress" atomically (e.g. INSERT ... ON CONFLICT DO NOTHING, or SETNX in Redis). The second request, seeing the key "locked," either waits or gets a 409.
Notice that PUT and DELETE are inherently idempotent, so you don't need an Idempotency-Key for them; retrying is safe by itself. Only POST (and sometimes PATCH), which "creates something new each time," needs this protection. Say this distinction in an interview and you show you've genuinely understood the safe/idempotent split rather than just memorized it.
Pagination, filtering, and sorting
Never return an unbounded collection. If GET /orders dumps a million orders at once, both your server and the client choke. You must paginate. There are two main approaches.
offset versus cursor
Offset pagination is like saying "start at page 20, give me 10." The database has to count and skip the first twenty pages to reach the right spot — and if pages get inserted into the book while you're reading, the numbers shift and you might see a row twice or miss one. But a cursor is like a bookmark: you say "continue from where I was last time." The database jumps straight to that point without counting what's before it, and newly inserted rows don't move your bookmark.
| Property | offset/limit | cursor (keyset) |
|---|---|---|
| Example | ?page=3&size=20 |
?limit=20&after=eyJpZCI6MTQwfQ |
| Performance on big data | Bad; the larger the OFFSET, the slower |
Excellent; independent of page depth |
| Jump to an arbitrary page | Easy (page 50!) | Impossible; only next/previous |
| Stability under insert/delete | Unstable; rows slide | Stable |
| Total page count | Simple | Usually not provided |
The cursor logic rests on this: instead of "skip 20," you say "give me all rows whose id is greater than the last id of the previous page." Because you jump on an indexed column, the speed stays constant.
// Keyset-based cursor: get the next page from the last row's id
@GetMapping("/orders")
public PageResponse<Order> list(
@RequestParam(required = false) Long after,
@RequestParam(defaultValue = "20") int limit) {
int capped = Math.min(limit, 100); // always set a cap!
List<Order> rows = (after == null)
? repo.findFirstPage(capped + 1) // fetch one extra
: repo.findAfter(after, capped + 1); // to know if a next page exists
boolean hasNext = rows.size() > capped;
if (hasNext) rows.remove(rows.size() - 1); // drop the extra row
Long nextCursor = hasNext ? rows.get(rows.size() - 1).id() : null;
return new PageResponse<>(rows, nextCursor);
}
See how we fetched capped + 1? That's a classic trick: instead of a separate query to know "does a next page exist," you just fetch one extra row. If the count returned exceeds the cap, a next page exists; you throw away that extra row. One fewer query, and no expensive COUNT(*).
Filtering and sorting
Do filtering and sorting with query parameters, not by building separate endpoints:
GET /orders?status=SHIPPED&minTotal=100&sort=createdAt,desc
If you take sort=createdAt and paste it directly into ORDER BY, you've opened a door for SQL injection. Always keep an allow-list of permitted column names and match the user's input against it. Likewise, restrict filters to a controlled query language; don't let users run arbitrary expressions.
Error format: the problem+json standard
The biggest inconsistency in APIs is the error format. Every team invents its own shape: one uses {"error": "..."}, another {"message": "...", "code": 42}, another raw text. The consumer has to relearn it for each API. This is where a standard saves you.
Imagine each city built its "STOP" sign in its own shape and color. Driving in a strange city would be a nightmare. That's why signs are standardized: everywhere in the world, a red octagon means stop. problem+json is that same standardization for API errors: a machine-readable shape that every client recognizes in advance.
The original standard was RFC 7807 (2016), which was revised and superseded by RFC 9457 (published July 2023). RFC 9457 subsumes all of RFC 7807 and adds a few improvements (like better support for "multiple problems at once" and a shared registry of problem types). The Content-Type for these responses is application/problem+json. The standard fields:
type: a URI that identifies the type of problem (e.g. a link to that error's docs).title: a short, human-readable summary of the problem type.status: the HTTP status code, echoed in the body.detail: a human explanation specific to this occurrence.instance: a URI that identifies this particular occurrence.- Plus any extension fields you like (e.g.
errorsfor validation details).
Good news for Java folks: since Spring Boot 3.0, this standard is supported natively through the ProblemDetail class. You just flip a property or extend ResponseEntityExceptionHandler.
// Option 1: enable globally in application.properties
// spring.mvc.problemdetails.enabled=true
// Option 2: handle errors manually with ProblemDetail
@RestControllerAdvice
public class ApiExceptionHandler {
@ExceptionHandler(OrderNotFoundException.class)
public ProblemDetail handleNotFound(OrderNotFoundException ex) {
ProblemDetail pd = ProblemDetail.forStatusAndDetail(
HttpStatus.NOT_FOUND, ex.getMessage());
pd.setType(URI.create("https://api.example.com/errors/order-not-found"));
pd.setTitle("Order Not Found");
pd.setInstance(URI.create("/orders/" + ex.getOrderId()));
pd.setProperty("orderId", ex.getOrderId()); // extension field
pd.setProperty("timestamp", Instant.now());
return pd; // Spring sets Content-Type to application/problem+json
}
}
The produced response looks like this:
{
"type": "https://api.example.com/errors/order-not-found",
"title": "Order Not Found",
"status": 404,
"detail": "No order exists with id 8801",
"instance": "/orders/8801",
"orderId": 8801,
"timestamp": "2026-07-20T10:15:30Z"
}
A deadly security trap: never put the raw exception message or stack trace in detail. A line like NullPointerException at com.example.PaymentService.line 88 hands an attacker the internal map of your system. For 500 errors, give a safe generic message and keep the details only in your server-side logs (with a traceId present in both the log and the response, so support can trace it).
HATEOAS: REST's most ambitious part
HATEOAS stands for Hypermedia As The Engine Of Application State — Level 3 of the Richardson model.
When you browse a site, you don't memorize the next page's address; you just click links. The site itself tells you where you can go from here. HATEOAS brings that same idea to an API: each resource's response, besides the data, also gives links to the possible next actions. The client doesn't have to hardcode URLs; it just follows the links inside the response.
For example, an order's response gives different links depending on its state:
{
"id": 8801,
"status": "PENDING",
"total": 250.00,
"_links": {
"self": { "href": "/orders/8801" },
"cancel": { "href": "/orders/8801/cancel", "method": "POST" },
"pay": { "href": "/orders/8801/payment", "method": "POST" }
}
}
If the order had already been paid, the pay link would no longer appear. That is, the server tells the client "which actions are legal right now" and the client doesn't have to duplicate the state logic inside itself. In Spring, the Spring HATEOAS library builds these links with EntityModel and WebMvcLinkBuilder.
Although theoretically elegant, in practice most teams don't fully implement HATEOAS, because: (1) mobile and SPA clients usually already know the URLs and don't follow links, (2) it inflates response complexity and size, (3) code-generation tooling supports it less. So it's a conscious decision: if your consumers truly benefit from links (say, a complex workflow with state transitions), it's worth it; otherwise Level 2 is enough.
Versioning and backward compatibility
This is the most serious senior part of API design. The moment the first client connects to your API, you no longer "freely own" your contract; you are responsible to that client.
When you rent out a house, you can't say mid-lease "rent doubles tomorrow" or "I removed the front door." You must honor the terms until the lease ends; you hold your changes for the next term. An API is the same: clients connected to the current version today have the right to see the same behavior tomorrow. A breaking change must go into a "new version," not pulled out from under the current client.
Breaking versus non-breaking changes
First you must know what breaks a client:
- Non-breaking (backward-compatible): adding a new optional field, adding a new endpoint, adding a new value to an output enum (carefully). An old client ignores these and keeps working.
- Breaking: removing or renaming a field, changing a field's type (string to number), making a previously optional input field required, changing the meaning of a status code, tightening validation rules.
The rule that must live in your bones: only add, never remove or change. This is also called the "robustness principle": be strict in what you send, lenient in what you accept. If clients ignore unknown fields (which they should), you can add fields for years without breaking anyone. Most need for a "new version" arises precisely when this principle wasn't honored.
Four versioning strategies
| Strategy | Example | Advantage | Drawback |
|---|---|---|---|
| URI path | /v2/users |
Simple, clear, cacheable, testable in a browser | "Impure REST"; a resource URI shouldn't change |
| Query param | /users?version=2 |
Simple | Complicates caching and routing |
| Custom header | X-API-Version: 2 |
URI stays clean | Invisible; not testable in a browser |
| Media type (content negotiation) | Accept: application/vnd.example.v2+json |
"Purest" from a REST view | Complex, unfamiliar to many |
In practice, URI-path versioning (like /v1/...) is the most common and pragmatic choice, because it's transparent and everyone understands it. Hardcore REST purists consider media-type versioning "more correct," but its operational cost is high.
If you're going to use URI versioning, put v1 at the start of the whole API's path (/api/v1/users, /api/v1/orders), rather than giving each resource its own version. Managing dozens of resources with mismatched versions is a nightmare. One version for the entire API surface is simple and comprehensible.
Every new version means more code you must maintain, test, and secure in parallel. The goal is not to spin up /v2, /v3 for every small change; the goal is to postpone the need for a new version as long as possible with the additive principle. When you do have to version, announce a clear deprecation policy: how long the old version lives, and warn clients with the Deprecation and Sunset headers.
OpenAPI and contract-first design
Now we reach the tool that ties all of this into one formal document: OpenAPI.
Before the mason lays a brick, the architect draws a blueprint that everyone — the owner, the electrician, the plumber — works from. The blueprint is the "source of truth." OpenAPI is that blueprint for an API: a file (YAML or JSON) that says exactly which endpoints exist, what input they take, what output they give, and what errors are possible. From this one file, documentation, client code, and tests are all generated.
OpenAPI (formerly called Swagger) is the dominant standard for describing REST APIs. The versions:
- OpenAPI 3.1 (February 2021): a major milestone — the first version to become fully aligned with JSON Schema (draft 2020-12). That means the very schema you use to validate data is directly usable in the API description.
- OpenAPI 3.2 (September 2025): the newest release; additions like structured tag navigation, streaming-friendly media types, and fresh OAuth flows on top of that JSON-Schema-aligned foundation.
Two styles: code-first and contract-first
In code-first you write the code first, then a tool takes a "photo" (the OpenAPI document) of your code. It's fast, but the document always trails the code. In contract-first you first draw the contract "sketch" (the OpenAPI file), teams agree on it, and then server and client code are generated from it. It starts slower but guarantees cross-team alignment.
In the Java world the popular library is springdoc-openapi, which auto-generates the OpenAPI document and the interactive Swagger UI from Spring annotations. A current note: since version 2.8.0, springdoc produces OpenAPI 3.1 by default.
// A code-first example with springdoc-openapi
@RestController
@RequestMapping("/api/v1/orders")
@Tag(name = "Orders", description = "Order management")
public class OrderController {
@Operation(summary = "Read an order by id")
@ApiResponses({
@ApiResponse(responseCode = "200", description = "Order found"),
@ApiResponse(responseCode = "404", description = "Order does not exist",
content = @Content(mediaType = "application/problem+json"))
})
@GetMapping("/{id}")
public Order getOrder(
@Parameter(description = "The order id") @PathVariable Long id) {
return service.findById(id);
}
}
And as soon as you add the springdoc-openapi-starter-webmvc-ui dependency, live docs come up at /swagger-ui.html and the raw document at /v3/api-docs.
But in the contract-first style, you write the OpenAPI file first:
openapi: 3.1.0
info:
title: Orders API
version: 1.0.0
paths:
/orders/{id}:
get:
operationId: getOrder
parameters:
- name: id
in: path
required: true
schema: { type: integer, format: int64 }
responses:
'200':
description: OK
content:
application/json:
schema: { $ref: '#/components/schemas/Order' }
'404':
description: Not Found
content:
application/problem+json:
schema: { $ref: '#/components/schemas/Problem' }
components:
schemas:
Order:
type: object
required: [id, status, total]
properties:
id: { type: integer, format: int64 }
status: { type: string, enum: [PENDING, PAID, SHIPPED] }
total: { type: number }
Then, with a tool like OpenAPI Generator, this file produces both the server-side interfaces (that your code must implement) and ready-made clients for various languages.
When frontend and backend teams must work simultaneously, contract-first "freezes" one contract so both teams can proceed in parallel without waiting on each other. The OpenAPI document becomes a shared source of truth; if the backend drifts from the contract, contract tests fail and it's caught early. That means moving integration bugs from "discovered in production" to "discovered at build time."
Rate limiting: protecting the server from a flood of requests
If water enters a pipe at infinite pressure, the pipe bursts. A pressure-reducing valve caps the flow to a safe limit. Rate limiting is that: no single client should be able to knock over your server with a flood of requests (whether out of malice, or a bug — an infinite loop in the client's code). You set the limit to preserve fairness and stability for all consumers.
When a client exceeds its quota, the correct status code is 429 Too Many Requests. But merely rejecting is not enough; you must tell the client "how much is left" and "when to retry." For this, an IETF draft named draft-ietf-httpapi-ratelimit-headers is standardizing the headers:
RateLimit: the current quota status (how much is left and when it resets).RateLimit-Policy: the quota policy the server advertises.- And on a 429 response, the standard
Retry-Afterheader telling how many seconds to wait.
@Component
public class RateLimitFilter extends OncePerRequestFilter {
private final RateLimiter limiter; // e.g. token-bucket on Redis
@Override
protected void doFilterInternal(HttpServletRequest req,
HttpServletResponse res, FilterChain chain)
throws ServletException, IOException {
String clientId = resolveClient(req); // by API key or IP
Quota quota = limiter.tryConsume(clientId);
res.setHeader("RateLimit-Remaining", String.valueOf(quota.remaining()));
res.setHeader("RateLimit-Reset", String.valueOf(quota.resetSeconds()));
if (!quota.allowed()) {
res.setStatus(429);
res.setHeader("Retry-After", String.valueOf(quota.resetSeconds()));
res.setContentType("application/problem+json");
res.getWriter().write("""
{"type":"about:blank","title":"Too Many Requests","status":429}""");
return;
}
chain.doFilter(req, res);
}
}
The most common rate-limiting algorithm is the "token bucket": each client has a bucket with a fixed capacity that refills at a steady rate; each request consumes a token, and if the bucket is empty, it gets a 429. Its beauty is that it both caps an average rate and allows a "short burst" — because the bucket can fill up to capacity. In a distributed system, you keep the counter on Redis so all server instances share one view.
Authentication: API keys versus OAuth
The last design piece is who can touch your API. Two common approaches:
| Property | API key | OAuth 2.0 / 2.1 |
|---|---|---|
| What it is | A static secret string | A delegated-access framework with tokens |
| Whose identity | Usually an application/service | A user or app, with scopes |
| Fine-grained access | None; all-or-nothing | Yes; via scopes and short-lived tokens |
| Expiry | Usually unlimited until manually revoked | Short-lived access token + refresh |
| Good use | Server-to-server, simple public APIs | User access, third-party apps |
| Risk | A leak means full, permanent access | Token leak, bounded by its short life |
An API key is simple and convenient, but it has weaknesses: because it's a static string, if it leaks in a log, a URL, or on GitHub, an attacker has full, permanent access. So: (1) never put an API key in the query string (it stays in logs); put it in the Authorization header or a dedicated header. (2) Give it rotation and instant-revocation capability. (3) For user or third-party access, reach for OAuth instead.
A current note on OAuth: OAuth 2.1 (still a draft but practically adopted — even Anthropic's MCP protocol is built on it) makes several security decisions that were previously "recommended" now mandatory:
- PKCE for all clients became mandatory (previously recommended only for public clients).
- The implicit flow was removed entirely, because it exposed the token directly in the browser.
- The password grant (ROPC) was deprecated.
- The token must never go in a query string; only in the
Authorizationheader. - Exact redirect-URI matching (no wildcards).
The distinction everyone must know: authentication is "who are you" and authorization is "what are you allowed to do." An API key mostly answers the first question (which app), whereas OAuth with scopes covers both and — more importantly — shrinks the "blast radius" of a leak with short-lived tokens. Choosing between the two is not taste; it's a function of "who the consumer is and how fine-grained the access must be."
Common pitfalls
Let's gather the top traps in one place — the ones that bite in interviews or real code:
- Verbs in the URI:
/getUser,/createOrder— name the resource with a noun and leave the verb to HTTP. - Everything 200: don't return errors with a 200 code and the message in the body; give the correct status code.
- Unbounded collections: without pagination, a
GET /orderscan knock over your server. Always set a cap. - POST without idempotency: in money/order APIs, no
Idempotency-Keymeans a double-charge on retry. - Breaking change without versioning: removing or renaming a field in the existing version breaks production clients.
- Leaking internal details in errors: a stack trace in the response = an attack map for the attacker.
sortstraight into SQL: a door for SQL injection; always allow-list.- API key in the query string: it lingers in logs and browser history; put it in a header.
Best practices
- Model resources with plural nouns (
/users, not/useror/getUser) and use HTTP verbs correctly. - Choose status codes precisely; especially 201 with a
Locationheader, and the 401/403 and 400/422 distinctions. - Standardize errors with
application/problem+json(RFC 9457); in Spring Boot 3 useProblemDetail. - Prefer cursor pagination for collections, always with a capped
limit. - Evolve with the additive, not subtractive principle to reduce the need for new versions; version the whole API path.
- Document the API with OpenAPI; for multi-team collaboration consider contract-first.
- Put rate limiting and proper auth (API key for server-to-server, OAuth 2.1 for users) in place from day one.
Interview Questions
Now it's time to test everything through real senior-interview questions. Answer each yourself first, then open the answer.
Safe means the verb changes no state on the server (like GET). Idempotent means repeating the verb doesn't change the final effect. These are independent: DELETE is unsafe (it changes state) but idempotent (deleting a resource a second time just 404s, but the final state — the resource being gone — is the same). PUT is the same. POST is neither. This distinction comes from RFC 9110.
Because POST is inherently not idempotent: it creates a new resource each time, so a retry caused by a network timeout can create two resources (two payments). An Idempotency-Key is a unique client-supplied key that the server uses to detect retries and replay the previous response. But PUT and DELETE are inherently idempotent, so retrying them is safe by itself and they don't need this key.
401 Unauthorized means authentication failed — the server doesn't know who you are (no token, or invalid/expired). 403 Forbidden means authentication succeeded but you lack permission for this specific action — the server knows who you are, but you're not allowed. In short: 401 is about identity, 403 is about permission.
PUT is a full replacement of the resource: the body must contain the whole resource, and missing fields mean "empty/default." PATCH is a partial change: only the sent fields are modified. The classic trap is using PUT like PATCH (sending only changed fields); the result is that the rest of the fields get cleared and the user loses data.
Offset (like page=3&size=20) is simple and allows jumping to any page, but it slows down on large data (the database must skip all prior rows) and is unstable under insert/delete (rows slide). Cursor/keyset (like after=<id>) jumps on an indexed column, so its speed is independent of page depth and it's stable against insert/delete; but it can't jump to an arbitrary page. Use cursor for infinite feeds and big data; use offset for "page 5 of 10" UIs where the data is small.
The standard is RFC 9457 (the successor to RFC 7807, published 2023) with media type application/problem+json; fields type, title, status, detail, instance plus extension fields. Since Spring Boot 3, this standard is supported natively: either turn on spring.mvc.problemdetails.enabled=true, or extend ResponseEntityExceptionHandler, or return a ProblemDetail from a @RestControllerAdvice.
Breaking: removing/renaming a field, changing a type, making a previously optional input required, changing the meaning of a status code. Non-breaking: adding an optional field or a new endpoint. The golden rule: only add, don't remove/change (the robustness principle: strict in what you send, lenient in what you accept). If clients ignore unknown fields, you can add fields for years without a new version.
Four ways: URI path (/v1/), query param (?version=1), custom header (X-API-Version), and media type (Accept: application/vnd.x.v1+json). REST purists consider media type "most correct" because it keeps the resource URI stable. But in practice URI path is the most common because it's transparent, cacheable, and browser-testable. Version the whole API path, not each resource, and announce a clear deprecation policy.
OpenAPI (formerly Swagger) is the standard for describing REST APIs; version 3.1 became fully aligned with JSON Schema 2020-12, and 3.2 (2025) is the newest. In code-first you write the code first and a tool (like springdoc-openapi) extracts the document from it. In contract-first you write the OpenAPI file first, teams agree on it, and server/client code is generated from it; it wins for multi-team collaboration and contract testing because it creates a shared source of truth.
HATEOAS means each resource's response, besides the data, also gives links to the possible next actions, so the client follows links instead of hardcoding URLs — the highest Richardson maturity level (Level 3) and, per Fielding, "true REST." It's used less because mobile/SPA clients usually know the URLs in advance, response size and complexity rise, and code-generation tooling supports it less. Most APIs consciously stay at Level 2.
An API key is a static secret; simple, good for server-to-server and simple public APIs, but all-or-nothing and without expiry — a leak means full permanent access. OAuth 2.0/2.1 is a delegated-access framework with short-lived tokens and scopes; for user and third-party access, and the risk of a token leak is bounded by its short life. OAuth 2.1 now mandates PKCE for all, removes the implicit flow, and forbids tokens in the query. Never put an API key in the query string.
The client sends a unique Idempotency-Key (UUID) in a header. On receiving it, the server atomically inserts a record with status "in progress" under that key (INSERT ... ON CONFLICT DO NOTHING, or SETNX). If the insert succeeds, it performs the payment, stores the result under the key, and returns 201. If the key already existed: if the prior work finished, it replays the stored result; if still in progress, it returns 409 (or waits). The critical point: it's the atomic insert that closes the race condition between two concurrent requests — a mere "check-then-store" is not enough.
- An API is a contract, not just code; REST is an architectural style (not a protocol) with constraints like statelessness and a uniform interface.
- The Richardson model has four levels; most APIs consciously stay at Level 2 (correct verbs and status codes) and Level 3 (HATEOAS) is less common.
- Name resources with nouns, use verbs correctly, and give precise status codes (especially 401/403, 400/422, and 201).
- Idempotency with
Idempotency-Keysaves the life of payment POSTs; idempotent verbs (PUT/DELETE) don't need it. Watch out for the race condition. - Prefer cursor pagination for collections; do filtering/sorting with query params and an allow-list.
- Standardize errors with
problem+json(RFC 9457); Spring Boot 3 supports it natively viaProblemDetail. Don't leak internal details. - Evolve with the additive, not subtractive principle; version the whole API path and announce a deprecation policy.
- OpenAPI 3.1/3.2 formalizes your contract; contract-first wins for large teams (springdoc in Java).
- Put rate limiting (429 +
RateLimitheaders) and proper auth (API key for server-to-server, OAuth 2.1 for users) in place from day one.