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 بنویسی؛ قرار است بفهمی چرا هر تصمیم طراحی را می‌گیریم، کدام استانداردها امروز واقعاً معتبرند، و در مصاحبه‌ی سنیور چطور درباره‌ی «قرارداد» بین سرور و کلاینت حرف بزنی.

نقشه‌ی راه این فصل

با هم این مسیر را می‌رویم:

  1. مدل ذهنی بزرگ — چرا REST یک «سبک» است نه یک پروتکل، و API چیزی جز یک «قرارداد» نیست.
  2. بلوغ REST — مدل Richardson و چهار سطح آن.
  3. منابع، فعل‌ها و کدهای وضعیت — الفبای HTTP که خیلی‌ها اشتباه به کار می‌برند.
  4. Idempotency — چرا این کلمه‌ی سخت، جانِ APIهای پرداخت را نجات می‌دهد.
  5. صفحه‌بندی، فیلتر و مرتب‌سازی — offset در برابر cursor.
  6. فرمت خطا — استاندارد problem+json و RFC 9457.
  7. HATEOAS — بلندترین ادعای REST و واقعیت زمینی‌اش.
  8. نسخه‌بندی و سازگاری رو به عقب — چطور تغییر بدهی بی‌آنکه کسی را بشکنی.
  9. OpenAPI و طراحی قرارداد‌محور — قرارداد به‌عنوان منبع حقیقت.
  10. Rate limiting و احراز هویت — API key در برابر OAuth.
  11. دام‌ها، بهترین شیوه‌ها و ۱۲ پرسش مصاحبه با پاسخ کامل.

بخش صفر — چند کلمه که پیش از شروع باید حسشان کنی

قبل از کد، چهار اصطلاح هست که در کل فصل برمی‌گردند. بگذار همین حالا با تشبیه جاشان بیندازم.

API مثل منوی یک رستوران است

تصور کن به رستورانی می‌روی. تو (کلاینت) لازم نیست بدانی آشپزخانه چطور کار می‌کند، اجاق چند درجه است، یا گوشت را از کجا می‌خرند. تو فقط منو را نگاه می‌کنی: فهرستی از چیزهایی که می‌توانی سفارش بدهی و اینکه هر کدام چه چیزی به تو تحویل می‌دهد. منو همان قرارداد (contract) است. تا وقتی رستوران به منو وفادار بماند، آشپزخانه‌اش را می‌تواند هر طور خواست عوض کند و تو متوجه نمی‌شوی. API دقیقاً همین است: یک قرارداد بین دو نرم‌افزار که یکی خدمت می‌دهد و دیگری مصرف می‌کند، مستقل از اینکه پشت پرده چه خبر است.

  • منبع (resource): هر «چیز» قابل‌اشاره در سیستم تو — یک کاربر، یک سفارش، یک محصول. منبع یک مفهوم است، نه داده‌ی خام.
  • بازنمایی (representation): شکلی که یک منبع در یک لحظه به خودش می‌گیرد؛ مثلاً همان کاربر به‌صورت یک سند JSON. منبع «کاربر شماره ۷» یکی است، اما بازنمایی‌اش می‌تواند JSON یا XML باشد.
  • Idempotent (خنثی‌به‌تکرار): عملی که اگر یک بار انجامش بدهی یا صد بار، نتیجه‌ی نهایی روی سرور یکی است. خاموش‌کردن چراغ idempotent است؛ فشار «۱۰۰ بار» همان خاموش را می‌دهد. اما «یک قهوه سفارش بده» idempotent نیست: صد بار یعنی صد قهوه.
  • قرارداد (contract): توافق دقیق بر سرِ اینکه چه درخواستی چه پاسخی می‌گیرد. تمام این فصل درباره‌ی محکم و پایدار نگه‌داشتن این قرارداد است.

مدل ذهنی: REST یک سبک است، نه یک پروتکل

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های دنیای واقعی سطح ۲ هستند

بیایید صادق باشیم: بیشتر APIهای تولیدی امروز در سطح ۲ می‌مانند و همان‌جا هم خیلی خوب کار می‌کنند. سطح ۳ (HATEOAS) از نظر Fielding «REST واقعی» است، اما در عمل هزینه‌اش را کمتر جایی توجیه می‌کند. هدف این نیست که کورکورانه به سطح ۳ برسی؛ هدف این است که آگاهانه بدانی کجا ایستاده‌ای و چرا. در مصاحبه اگر بگویی «ما آگاهانه سطح ۲ ماندیم چون مصرف‌کننده‌های ما لینک‌ها را دنبال نمی‌کنند» این نشانه‌ی بلوغ است، نه ضعف.


منابع، فعل‌ها و کدهای وضعیت

منابع را با اسم نام‌گذاری کن، نه فعل

URI مثل آدرس یک خانه است، نه دستور کاری که آنجا انجام می‌دهی

آدرس خانه می‌گوید «خیابان لاله، پلاک ۷» — یک اسم، یک مکان. آدرس نمی‌گوید «رنگ‌کردن خانه‌ی پلاک ۷». کارِ لازم را با ابزارت (فعل) مشخص می‌کنی، نه با آدرس. در 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 تغییر جزئی است؛ فقط فیلدهایی که فرستادی عوض می‌شوند. اگر 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 و 403

این دو را همه اشتباه می‌کنند. 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);
    }
}
Idempotency فقط ذخیره‌کردن کلید نیست

دام ظریف: بین «چک‌کردن کلید» و «ذخیره‌ی نتیجه» یک شرایط رقابتی (race condition) هست. اگر دو درخواست با یک کلید همزمان بیایند، هر دو ممکن است چک کنند، هیچ‌کدام چیزی نبینند، و هر دو پرداخت را انجام دهند! راه‌حل درست: هنگام دریافت کلید، بلافاصله یک رکورد با وضعیت «در حال پردازش» به‌صورت اتمیک درج کن (مثلاً INSERT ... ON CONFLICT DO NOTHING یا SETNX در Redis). درخواست دوم که کلید را «قفل‌شده» می‌بیند، یا منتظر می‌ماند یا 409 می‌گیرد.

چرا فعل‌های idempotent به کلید نیاز ندارند

دقت کن که PUT و DELETE ذاتاً idempotent‌اند، پس برای آنها به Idempotency-Key نیاز نداری؛ تکرارشان به‌خودی‌خود امن است. فقط POST (و گاهی PATCH) که «هر بار یک چیز تازه می‌سازند» به این محافظت نیاز دارند. این تفاوت را در مصاحبه بگویی، نشان می‌دهی که تفکیک safe/idempotent را واقعاً فهمیده‌ای، نه اینکه فقط حفظ کرده باشی.


صفحه‌بندی، فیلتر و مرتب‌سازی

هیچ‌وقت یک کالکشن بی‌مرز برنگردان. اگر GET /orders یک میلیون سفارش را یک‌جا بریزد، هم سرورت خفه می‌شود هم کلاینت. باید صفحه‌بندی کنی. دو رویکرد اصلی هست.

offset در برابر cursor

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 را مستقیم به SQL نده

اگر 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"
}
هرگز stack trace یا پیام داخلی را لو نده

دام امنیتیِ کشنده: در detail هرگز پیام خام استثنا یا stack trace را قرار نده. جمله‌ای مثل NullPointerException at com.example.PaymentService.line 88 به مهاجم نقشه‌ی داخلی سیستمت را می‌دهد. برای خطاهای ۵۰۰، یک پیام عمومیِ امن بده و جزئیات را فقط در لاگ سمت سرور نگه دار (با یک traceId که هم در لاگ باشد هم در پاسخ، تا پشتیبانی بتواند دنبالش کند).


HATEOAS: بلندپروازانه‌ترین بخش REST

HATEOAS مخفف Hypermedia As The Engine Of Application State است — سطح ۳ مدل Richardson.

HATEOAS مثل وب‌گردی با لینک‌هاست، نه حفظ‌کردن آدرس‌ها

وقتی در یک سایت می‌گردی، آدرس صفحه‌ی بعد را حفظ نمی‌کنی؛ فقط روی لینک‌ها کلیک می‌کنی. سایت خودش می‌گوید از اینجا کجا می‌توانی بروی. 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 کمتر استفاده می‌شود

با اینکه از نظر تئوری زیباست، در عمل بیشتر تیم‌ها HATEOAS را کامل پیاده نمی‌کنند، چون: (۱) کلاینت‌های موبایل و SPA معمولاً URLها را از پیش می‌دانند و لینک‌ها را دنبال نمی‌کنند، (۲) پیچیدگی و حجم پاسخ بالا می‌رود، (۳) ابزارهای تولید کد کمتر پشتیبانی‌اش می‌کنند. پس یک تصمیم آگاهانه است: اگر مصرف‌کننده‌هایت واقعاً از لینک‌ها بهره می‌برند (مثلاً یک workflow پیچیده با گذارِ وضعیت)، ارزشش را دارد؛ وگرنه سطح ۲ کافی است.


نسخه‌بندی و سازگاری رو به عقب

این بخش، جدی‌ترین بخش سنیوریِ طراحی API است. لحظه‌ای که اولین کلاینت به 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ِ کل API بگذار، نه تک‌تک منابع

اگر می‌خواهی از نسخه‌بندی URI استفاده کنی، v1 را در ابتدای مسیرِ کل API بگذار (/api/v1/users, /api/v1/orders)، نه اینکه هر منبع نسخه‌ی جدا داشته باشد. مدیریتِ ده‌ها منبع با نسخه‌های ناهماهنگ کابوس است. یک نسخه برای کل سطحِ API، ساده و قابل‌فهم است.

نسخه‌ی جدید ارزان نیست

هر نسخه‌ی جدید یعنی کدِ بیشتری که باید همزمان نگه‌داری کنی، تست کنی و امن نگه داری. هدف این نیست که برای هر تغییر کوچک /v2، /v3 بسازی؛ هدف این است که با اصلِ افزودنی، نیاز به نسخه‌ی جدید را تا حد ممکن به تعویق بیندازی. وقتی مجبور به نسخه‌ی جدید شدی، یک سیاست کنارگذاری (deprecation policy) روشن اعلام کن: چقدر نسخه‌ی قدیمی زنده می‌ماند، و با هدر Deprecation و Sunset به کلاینت‌ها هشدار بده.


OpenAPI و طراحی قرارداد‌محور

حالا می‌رسیم به ابزاری که همه‌ی این‌ها را به یک سند رسمی گره می‌زند: 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 مثل عکس‌گرفتن است؛ 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 برای تیم‌های بزرگ برنده است

وقتی تیمِ فرانت و بک هم‌زمان باید کار کنند، contract-first یک قرارداد را «منجمد» می‌کند تا هر دو تیم موازی جلو بروند بی‌آنکه منتظر هم بمانند. سند OpenAPI منبعِ حقیقتِ مشترک می‌شود؛ اگر بک از قرارداد منحرف شد، تست‌های قرارداد (contract testing) شکست می‌خورند و زود گیر می‌افتد. این یعنی جابه‌جاییِ باگ‌های یکپارچگی از «کشف در محیط تولید» به «کشف در زمان بیلد».


Rate limiting: محافظت از سرور در برابر سیل درخواست

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);
    }
}
الگوریتم token bucket را بشناس

رایج‌ترین الگوریتم rate limiting «سطلِ توکن» است: هر کلاینت یک سطل با ظرفیت مشخص دارد که با نرخ ثابت توکن می‌گیرد؛ هر درخواست یک توکن مصرف می‌کند و اگر سطل خالی بود، 429 می‌گیرد. زیبایی‌اش این است که هم یک نرخِ متوسط را محدود می‌کند و هم اجازه‌ی «انفجارِ کوتاه» (burst) می‌دهد — چون سطل می‌تواند تا سقفِ ظرفیت پر شود. در سیستم توزیع‌شده، شمارنده را روی Redis نگه می‌داری تا همه‌ی نمونه‌های سرور یک دید مشترک داشته باشند.


احراز هویت: API key در برابر OAuth

آخرین قطعه‌ی طراحی، این است که «چه کسی» می‌تواند به API‌ات دست بزند. دو رویکرد رایج:

ویژگی API key OAuth 2.0 / 2.1
چیست یک رشته‌ی رازِ ثابت فریم‌ورکِ تفویضِ دسترسی با توکن
هویتِ چه کسی معمولاً یک اپلیکیشن/سرویس کاربر یا اپلیکیشن، با دامنه (scope)
دسترسیِ ریزدانه ندارد؛ همه‌یا‌هیچ دارد؛ با scope و توکنِ کوتاه‌عمر
انقضا معمولاً بی‌انتها تا لغو دستی access token کوتاه‌عمر + refresh
کاربردِ خوب ارتباطِ سرور‌به‌سرور، APIهای عمومیِ ساده دسترسیِ کاربر، اپ‌های شخص‌ثالث
ریسک لو رفتنش یعنی دسترسیِ کامل و دائم نشتِ توکن، محدود به عمرِ کوتاهش
API key نه احراز هویتِ کامل است نه مجوزدهیِ ریزدانه

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 و idempotent چیست؟ یک فعلِ ناامن اما idempotent مثال بزن.

safe یعنی فعل هیچ حالتی را روی سرور تغییر نمی‌دهد (مثل GET). idempotent یعنی تکرارِ فعل نتیجه‌ی نهایی را عوض نمی‌کند. این دو مستقل‌اند: DELETE ناامن است (حالت را تغییر می‌دهد) اما idempotent است (حذفِ یک منبع، بار دوم فقط 404 می‌دهد اما حالتِ نهایی — نبودِ منبع — همان است). PUT هم همین‌طور. POST هیچ‌کدام نیست. این تفکیک از RFC 9110 می‌آید.

۲) (تله) چرا POST به `Idempotency-Key` نیاز دارد اما PUT نه؟

چون POST ذاتاً idempotent نیست: هر بار یک منبعِ تازه می‌سازد، پس تلاشِ مجددِ ناشی از تایم‌اوتِ شبکه می‌تواند دو منبع (دو پرداخت) بسازد. Idempotency-Key یک کلیدِ یکتا از کلاینت است که سرور با آن، تلاش‌های مجدد را تشخیص می‌دهد و همان پاسخِ قبلی را بازپخش می‌کند. اما PUT و DELETE ذاتاً idempotent‌اند، پس تکرارشان به‌خودی‌خود امن است و به این کلید نیاز ندارند.

۳) فرقِ 401 و 403 دقیقاً چیست؟

401 Unauthorized یعنی احراز هویت شکست خورده — سرور نمی‌داند تو کی هستی (توکن نداری، نامعتبر یا منقضی است). 403 Forbidden یعنی احراز موفق بوده اما مجوزِ این کارِ خاص را نداری — سرور می‌داند تو کی هستی، ولی اجازه نداری. خلاصه: 401 درباره‌ی هویت، 403 درباره‌ی مجوز.

۴) فرقِ PUT و PATCH چیست و چه دامی دارد؟

PUT جایگزینیِ کامل منبع است: بدنه باید کلِ منبع را داشته باشد و فیلدهای غایب یعنی «خالی/پیش‌فرض». PATCH تغییرِ جزئی است: فقط فیلدهای فرستاده‌شده عوض می‌شوند. دامِ کلاسیک این است که کسی PUT را مثل PATCH استفاده کند (فقط فیلدهای تغییریافته را بفرستد)؛ نتیجه این‌که بقیه‌ی فیلدها پاک می‌شوند و کاربر داده از دست می‌دهد.

۵) offset pagination و cursor pagination را مقایسه کن. کِی کدام؟

offset (مثل page=3&size=20) ساده است و پرش به هر صفحه را ممکن می‌کند، اما روی داده‌ی بزرگ کند می‌شود (دیتابیس باید همه‌ی ردیف‌های قبل را رد کند) و هنگام درج/حذف ناپایدار است (ردیف‌ها می‌لغزند). cursor/keyset (مثل after=<id>) روی ستونِ ایندکس‌شده پرش می‌کند، پس سرعتش مستقل از عمقِ صفحه است و در برابر درج/حذف پایدار است؛ اما پرش به صفحه‌ی دلخواه ندارد. برای فیدهای بی‌نهایت و داده‌ی بزرگ cursor؛ برای UIهای «صفحه‌ی ۵ از ۱۰» که داده کوچک است offset.

۶) استانداردِ فرمتِ خطا چیست و در Spring Boot چطور پیاده می‌شود؟

استاندارد 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 چیست و تفاوتِ code-first و contract-first را توضیح بده.

OpenAPI (سابقاً Swagger) استانداردِ توصیفِ REST APIهاست؛ نسخه‌ی 3.1 با JSON Schema 2020-12 کاملاً همسو شد و 3.2 (۲۰۲۵) تازه‌ترین است. در code-first اول کد را می‌نویسی و ابزار (مثل springdoc-openapi) سند را از آن استخراج می‌کند. در contract-first اول فایلِ OpenAPI را می‌نویسی، تیم‌ها رویش توافق می‌کنند و کدِ سرور/کلاینت از آن تولید می‌شود؛ برای همکاریِ چندتیمی و contract testing برتری دارد چون یک «منبعِ حقیقتِ» مشترک می‌سازد.

۱۰) HATEOAS چیست، چرا سطح ۳ Richardson است و چرا کمتر استفاده می‌شود؟

HATEOAS یعنی پاسخِ هر منبع، علاوه بر داده، لینکِ کارهای بعدیِ ممکن را هم بدهد، تا کلاینت به‌جای hardcodeکردنِ URLها، لینک‌ها را دنبال کند — بالاترین سطحِ بلوغِ Richardson (سطح ۳) و از نظر Fielding «REST واقعی». کمتر استفاده می‌شود چون کلاینت‌های موبایل/SPA معمولاً URLها را از پیش می‌دانند، حجم و پیچیدگیِ پاسخ بالا می‌رود و ابزارهای تولیدِ کد کمتر پشتیبانی می‌کنند. بیشتر APIها آگاهانه در سطح ۲ می‌مانند.

۱۱) کِی API key و کِی OAuth؟ ریسکِ اصلیِ هرکدام چیست؟

API key یک رازِ ثابت است؛ ساده، خوب برای سرور‌به‌سرور و APIهای عمومیِ ساده، اما همه‌یا‌هیچ و بدونِ انقضا — لو رفتنش یعنی دسترسیِ کاملِ دائم. OAuth 2.0/2.1 فریم‌ورکِ تفویضِ دسترسی با توکنِ کوتاه‌عمر و scope است؛ برای دسترسیِ کاربر و اپ‌های شخص‌ثالث، و ریسکِ نشتِ توکن به عمرِ کوتاهش محدود می‌شود. OAuth 2.1 حالا PKCE را برای همه اجباری کرده، flowِ implicit را حذف کرده و توکن‌درِ‌query را ممنوع کرده. API key را هرگز در query string نگذار.

۱۲) (سناریو) یک POSTِ پرداخت طراحی کن که در برابرِ تلاشِ‌مجددِ شبکه امن باشد، و شرایطِ رقابتی‌اش را هم پوشش بده.

کلاینت یک 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.

Roadmap for this chapter

Here's the path we'll walk together:

  1. The big mental model — why REST is a style, not a protocol, and why an API is nothing more than a contract.
  2. REST maturity — Richardson's model and its four levels.
  3. Resources, verbs, and status codes — the HTTP alphabet that so many people misuse.
  4. Idempotency — why this hard word saves the life of payment APIs.
  5. Pagination, filtering, and sorting — offset versus cursor.
  6. Error format — the problem+json standard and RFC 9457.
  7. HATEOAS — REST's boldest claim and its earthly reality.
  8. Versioning and backward compatibility — how to change without breaking anyone.
  9. OpenAPI and contract-first design — the contract as the source of truth.
  10. Rate limiting and authentication — API keys versus OAuth.
  11. 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.

An API is like a restaurant menu

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

REST is like city zoning rules, not the blueprint of one house

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.
Why statelessness matters so much

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
Most real-world APIs are Level 2

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 URI is like a house's address, not a description of the work you do there

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).

The classic trap: PUT versus PATCH

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
The subtle difference between 401 and 403

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

Like an elevator button pressed many times

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);
    }
}
Idempotency is not just storing a key

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.

Why idempotent verbs need no key

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 is like counting book pages; cursor is like a bookmark

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);
}
The "fetch one extra" trick

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
Never feed sort input straight into SQL

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.

A standard error format is like standardized traffic signs

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. errors for 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"
}
Never leak a stack trace or internal message

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.

HATEOAS is like browsing the web with links, not memorizing addresses

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.

Why HATEOAS is used less

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.

An API contract is like a lease agreement

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 golden rule of evolution: additive, not subtractive

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.

Version the whole API's URI, not each resource

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.

A new version isn't free

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.

OpenAPI is like a building's architectural blueprint

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

code-first is like taking a photo; contract-first is like drawing the sketch

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.

Why contract-first wins for large teams

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

Rate limiting is like a pressure-reducing valve on a water pipe

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-After header 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);
    }
}
Know the token bucket algorithm

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 neither full authentication nor fine-grained authorization

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 Authorization header.
  • Exact redirect-URI matching (no wildcards).
The senior sentence on authn and authz

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 /orders can knock over your server. Always set a cap.
  • POST without idempotency: in money/order APIs, no Idempotency-Key means 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.
  • sort straight 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 /user or /getUser) and use HTTP verbs correctly.
  • Choose status codes precisely; especially 201 with a Location header, and the 401/403 and 400/422 distinctions.
  • Standardize errors with application/problem+json (RFC 9457); in Spring Boot 3 use ProblemDetail.
  • 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.

1) What's the difference between safe and idempotent? Give an example of a verb that is unsafe but idempotent.

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.

2) (Trap) Why does POST need an `Idempotency-Key` but PUT doesn't?

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.

3) What exactly is the difference between 401 and 403?

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.

4) What's the difference between PUT and PATCH, and what's the trap?

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.

5) Compare offset pagination and cursor pagination. When each?

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.

6) What is the error-format standard and how is it implemented in Spring Boot?

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.

7) What change is "breaking," and how can you evolve without breaking?

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.

8) Name the versioning strategies and say which you'd pick in practice and why.

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.

9) What is OpenAPI, and explain the difference between code-first and contract-first.

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.

10) What is HATEOAS, why is it Richardson Level 3, and why is it used less?

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.

11) When API key and when OAuth? What's the main risk of each?

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.

12) (Scenario) Design a payment POST that's safe against network retries, and cover its race condition.

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.


In a nutshell
  • 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-Key saves 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 via ProblemDetail. 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 + RateLimit headers) and proper auth (API key for server-to-server, OAuth 2.1 for users) in place from day one.