ЁЯПл The SchoolтА║ЁЯУР System DesignтА║ЁЯФМ рдзрдбрд╛ 03 тАФ API design: рдЖрдзреА рдХрд░рд╛рд░
ЁЯЦ╝я╕П See the drawing + lab ЁЯПа Course home ЁЯМ┐ Branch on GitHub тЬПя╕П View source
ЁЯЦ╝я╕П рдЖрдХреГрддреА рдЖрдгрд┐ labThe drawing + lab рдкреВрд░реНрдг рдкрд╛рдирд╛рд╡рд░ рдЙрдШрдбрд╛ тЖЧOpen full page тЖЧ

ЁЯФМ рдзрдбрд╛ 03 тАФ API design: рдЖрдзреА рдХрд░рд╛рд░

ЁЯУН рддреБрдореНрд╣реА рдЗрдереЗ рдЖрд╣рд╛рдд: 18 рдкреИрдХреА рдзрдбрд╛ 03 ┬╖ рдорд╛рдЧреЗ: lesson-02-capacity ┬╖ рдкреБрдвреЗ: lesson-04-data-model


ЁЯУж рдпрд╛ рдмреНрд░рдБрдЪрдордзреНрдпреЗ рдХрд╛рдп рдЖрд╣реЗ

рдзрдбреЗ 01тАУ02, рдЖрдгрд┐ рдкрджреНрдзрддреАрдЪреА рддрд┐рд╕рд░реА рдкрд╛рдпрд░реА: app рдЖрдгрд┐ server рдпрд╛рдВрдЪреНрдпрд╛рддрд▓рд╛ рдХрд░рд╛рд░ (contract), code рдЪреНрдпрд╛ рдЖрдзреА рд▓рд┐рд╣рд┐рд▓реЗрд▓рд╛. рдХреГрддреАрдВрдРрд╡рдЬреА resources, рдпреЛрдЧреНрдп status codes, рдкреБрдиреНрд╣рд╛ рдкрд╛рдард╡рд▓реЗрд▓реА post рджреЛрдирджрд╛ рд▓рд╛рдЧреВ рдирдпреЗ рдореНрд╣рдгреВрди idempotency keys, OFFSET рдРрд╡рдЬреА cursor pagination, рдЖрдгрд┐ рдкреНрд░рдХрд╛рд╢рд┐рдд field рдХрдзреАрд╣реА рди рдореЛрдбрдгрд╛рд░реЗ versioning. design/demo.py рдордзреАрд▓ api() рд╕реВрдЪрдирд╛ рдлрд▓рдХрд╛рдЪрд╛ рдХрд░рд╛рд░ рдЫрд╛рдкрддреЗ; design/designs.py рдордзреАрд▓ pagination_cost() рдкреНрд░рддреНрдпреЗрдХ рдкреНрд░рдХрд╛рд░рдЪреЗ pagination database рд▓рд╛ рдХрд┐рддреА rows рдЪрд╛рд▓рд╛рдпрд▓рд╛ рд▓рд╛рд╡рддреЗ рддреЗ рдореЛрдЬрддреЗ.

ЁЯзТ 5 рд╡рд░реНрд╖рд╛рдВрдЪреНрдпрд╛ рдореБрд▓рд╛рд▓рд╛ рд╕рдордЬрд╛рд╡рд▓реНрдпрд╛рд╕рд╛рд░рдЦреЗ

рдирд┐рдпреЛрдЬрди рдХрд╛рд░реНрдпрд╛рд▓рдп рд╕реНрд╡рддрдГ рд╣реЙрд▓ рдмрд╛рдВрдзрдгрд╛рд░ рдирд╛рд╣реА. рдмрд╛рдВрдзрдХрд╛рдо рдХрд░рдгрд╛рд░реЗ рдмрд╛рдВрдзрддреАрд▓ тАФ рдЖрдгрд┐ рд╕реБрддрд╛рд░, рд╡реАрдЬрддрдВрддреНрд░реА рдЖрдгрд┐ рд░рдВрдЧрд╛рд░реА рд╕рдЧрд│реЗ рдПрдХрд╛рдЪ рд╡реЗрд│реА рдХрд╛рдо рдХрд░рддрд╛рдд. рдкреНрд░рддреНрдпреЗрдХрд╛рдиреЗ рджрд╛рд░реЗ рдХреБрдареЗ рдЖрд╣реЗрдд рдпрд╛рдЪрд╛ рдЕрдВрджрд╛рдЬ рдмрд╛рдВрдзрд▓рд╛, рддрд░ рдХрд╛рд╣реАрдЪ рдЬреБрд│рдгрд╛рд░ рдирд╛рд╣реА.

рдореНрд╣рдгреВрди рджреАрдкрд┐рдХрд╛ рдЖрдзреА рджрд╛рд░рд╛рдВрдЪрд╛ рдЖрд░рд╛рдЦрдбрд╛ ЁЯЪк рдХрд╛рдврддреЗ: рд╣реЗ рджрд╛рд░ рдореБрд▓рд╛рдВрдирд╛ рдЖрдд рдпреЗрдгреНрдпрд╛рд╕рд╛рдареА, рддреА рдЦрд┐рдбрдХреА рдлреЙрд░реНрдо рд╡рд╛рдЯрдгреНрдпрд╛рд╕рд╛рдареА, рдЖрдгрд┐ рдкреНрд░рддреНрдпреЗрдХ рджрд╛рд░рд╛рд╡рд░ рддрд┐рдереЗ рдХрд╛рдп рд╣реЛрддреЗ рддреЗ рд╕рд╛рдВрдЧрдгрд╛рд░реА рдкрд╛рдЯреА. рджрд╛рд░рд╛рдВрдЪреНрдпрд╛ рдЖрд░рд╛рдЦрдбреНрдпрд╛рд╡рд░ рд╕рд╣реА рдЭрд╛рд▓реА рдХреА рд╕рдЧрд│реЗ рдПрдХрд╛рдЪ рд╡реЗрд│реА рдХрд╛рдо рдХрд░реВ рд╢рдХрддрд╛рдд. App team рдЖрд░рд╛рдЦрдбреНрдпрд╛рдкреНрд░рдорд╛рдгреЗ phone app рдмрд╛рдВрдзрддреЗ; server team рдЖрд░рд╛рдЦрдбреНрдпрд╛рдкреНрд░рдорд╛рдгреЗ server рдмрд╛рдВрдзрддреЗ.

рджреАрдкрд┐рдХрд╛рдЪреНрдпрд╛ рдЖрд░рд╛рдЦрдбреНрдпрд╛рдд рдЖрдгрдЦреА рджреЛрди рдирд┐рдпрдо:

ЁЯЧ║я╕П рдЖрдХреГрддреА

flowchart LR
    app["ЁЯУ▒ parent / teacher app"]
    subgraph api["ЁЯФМ the contract ┬╖ /v1"]
      p["POST /classes/3A/notices<br/>Idempotency-Key<br/>тЖТ 201 + Location"]
      g["GET /classes/3A/notices<br/>?limit=20&cursor=тАж<br/>тЖТ 200 {items, next_cursor}"]
      f["GET /parents/me/feed<br/>?limit=20"]
    end
    err["тЪая╕П 400 ┬╖ 401 ┬╖ 403 ┬╖ 404 ┬╖ 409 ┬╖ 429"]
    pg["ЁЯУД page 5,000<br/>OFFSET walks 100,000 rows<br/>cursor walks 20"]
    app --> p
    app --> g
    app --> f
    api --> err
    g --> pg

ЁЯЧ║я╕П рдХрд╛рдврд▓реЗрд▓реА рдЖрд╡реГрддреНрддреА + рдПрдХ lab: https://school-edh.pages.dev/system-design/lesson-diagrams.html#l03

тЭУ рдХрд╛рдп

ЁЯдФ рдХрд╛

рдХрд╛рд░рдг API рд╣рд╛ рдирдВрддрд░ рдмрджрд▓рд╛рдпрд▓рд╛ рд╕рд░реНрд╡рд╛рдд рдЕрд╡рдШрдб рднрд╛рдЧ рдЖрд╣реЗ. рддреНрдпрд╛рдорд╛рдЧрдЪрд╛ database рддреБрдореНрд╣реА рдПрдХрд╛ weekend рдордзреНрдпреЗ рдмрджрд▓реВ рд╢рдХрддрд╛. 5 million phones рдирд╛ app update рдХрд░рд╛рдпрд▓рд╛ рд▓рд╛рд╡реВ рд╢рдХрдд рдирд╛рд╣реА. рдЖрдгрд┐ рдХрд░рд╛рд░ рд░рдЪрдиреЗрдЪреА рд╡рдЪрдиреЗ рд╡рд╛рд╣рддреЛ: network рдмрд┐рдШрдбрд▓реЗ рддрд░реА idempotency key "рд╕реВрдЪрдирд╛ рдХрдзреАрд╣реА рджреЛрдирджрд╛ post рд╣реЛрдд рдирд╛рд╣реА" рд╣реЗ рдЦрд░реЗ рдареЗрд╡рддреЗ; cursor pagination page 5,000 рд╡рд░рд╣реА "read p99 < 200 ms" рдЦрд░реЗ рдареЗрд╡рддреЗ; 403 "рдкрд╛рд▓рдХрд╛рд▓рд╛ рдлрдХреНрдд рддрд┐рдЪреНрдпрд╛ рдореБрд▓рд╛рдВрдЪреЗ рд╡рд░реНрдЧ рджрд┐рд╕рддрд╛рдд" рд╣реЗ рдХрд░рд╛рд░рд╛рддрдЪ рджрд┐рд╕рдгрд╛рд░реЗ рдареЗрд╡рддреЗ (рдзрдбрд╛ 14).

ЁЯФз рдХрд╕реЗ (рдпрд╛ repo рдордзреНрдпреЗ)

design/demo.py рдордзреАрд▓ api() рддреАрди calls рдЖрдгрд┐ errors рдЫрд╛рдкрддреЗ, рдордЧ design/designs.py рдордзреАрд▓ pagination_cost(page, page_size, mode) рд▓рд╛ рд╡рд┐рдЪрд╛рд░рддреЗ рдХреА рдкреНрд░рддреНрдпреЗрдХ mode рдХрд┐рддреА rows рдЪрд╛рд▓рддреЛ: OFFSET рд╕рд╛рдареА page ├Ч page_size, cursor рд╕рд╛рдареА page_size. Snippet рдордзреНрдпреЗ рддреБрдореНрд╣реА рдПрдХ рдЫреЛрдЯреЗ idempotency store рд╕реБрджреНрдзрд╛ рдЪрд╛рд▓рд╡рддрд╛ тАФ key рдкрд╛рд╕реВрди рдЙрддреНрддрд░рд╛рдкрд░реНрдпрдВрддрдЪреА рдПрдХ dictionary тАФ рдЖрдгрд┐ timeout рдирдВрддрд░ phone рдХрд░рддреЛ рддрд╢реА рддреАрдЪ key рджреЛрдирджрд╛ рдкрд╛рдард╡рддрд╛.

ЁЯзк рдХрд░реВрди рдкрд╛рд╣рд╛

python3 design/demo.py api
python3 - <<'EOF'
import sys; sys.path.insert(0, "design"); from designs import pagination_cost
for size in (20, 100):
    for page in (1, 500, 50_000):
        print(f"{size:>3} per page, page {page:>6,}: OFFSET {pagination_cost(page, size, 'offset'):>9,} rows ┬╖ cursor {pagination_cost(page, size, 'cursor'):>3}")
seen = {}
def post(key, body):
    if key in seen: return 200, seen[key], "replayed"
    seen[key] = f"notice-{len(seen) + 1}"; return 201, seen[key], "created"
print(post("k-7f3a", {"title": "Trip"}))
print(post("k-7f3a", {"title": "Trip"}))   # the phone retried after a timeout
print(post("k-9b21", {"title": "Exam"}))
EOF

тЬЕ рддрдкрд╛рд╕рд╛ тАФ рддреБрдореНрд╣рд╛рд▓рд╛ рдХрд╛рдп рджрд┐рд╕рд╛рдпрд▓рд╛ рд╣рд╡реЗ

api рдЫрд╛рдкрддреЗ:

тФАтФА the contract first тАФ resources, not actions:
   POST /classes/3A/notices          body {title, text, urgent} ┬╖ header Idempotency-Key тЖТ 201 + Location
   GET  /classes/3A/notices?limit=20&cursor=eyJpZCI6OTB9 тЖТ 200 {items, next_cursor}
   GET  /parents/me/feed?limit=20   тЖТ the notices of all my children's classes
   errors: 400 bad input ┬╖ 401 who are you ┬╖ 403 not your class ┬╖ 404 ┬╖ 409 key reused with a different body ┬╖ 429 slow down
тФАтФА pagination: rows the database walks to show page N (20 per page)
   page     1: OFFSET walks      20 rows ┬╖ a cursor walks  20
   page    50: OFFSET walks   1,000 rows ┬╖ a cursor walks  20
   page  5000: OFFSET walks 100,000 rows ┬╖ a cursor walks  20
   version in the path (/v1) or a header; never break a published field тАФ add new ones

рддреБрдордЪрд╛ snippet рдЫрд╛рдкрддреЛ:

 20 per page, page      1: OFFSET        20 rows ┬╖ cursor  20
 20 per page, page    500: OFFSET    10,000 rows ┬╖ cursor  20
 20 per page, page 50,000: OFFSET 1,000,000 rows ┬╖ cursor  20
100 per page, page      1: OFFSET       100 rows ┬╖ cursor 100
100 per page, page    500: OFFSET    50,000 rows ┬╖ cursor 100
100 per page, page 50,000: OFFSET 5,000,000 rows ┬╖ cursor 100
(201, 'notice-1', 'created')
(200, 'notice-1', 'replayed')
(201, 'notice-2', 'created')

ЁЯПБ рддреБрдореНрд╣реА рдЖрддреНрддрд╛рдЪ рдХрд╛рдп рд╕рд┐рджреНрдз рдХреЗрд▓реЗ

OFFSET рдЪрд╛ рдЦрд░реНрдЪ page рдХреНрд░рдорд╛рдВрдХрд╛рд╕реЛрдмрдд рд╡рд╛рдврддреЛ тАФ page 50,000 20 рд╕реВрдЪрдирд╛ рджрд╛рдЦрд╡рдгреНрдпрд╛рд╕рд╛рдареА рджрд╣рд╛ рд▓рд╛рдЦ rows рдЪрд╛рд▓рддреЛ. Cursor рдЪрд╛ рдЦрд░реНрдЪ page 1 рдЖрдгрд┐ page 50,000 рд╡рд░ рд╕рд╛рд░рдЦрд╛рдЪ. рдЖрдгрд┐ рддреАрдЪ Idempotency-Key рджреЛрдирджрд╛ рдкрд╛рдард╡рд▓реА рдХреА рддреАрдЪ рд╕реВрдЪрдирд╛ рдкрд░рдд рдорд┐рд│рддреЗ (notice-1, рджреБрд╕рд▒реНрдпрд╛ рд╡реЗрд│реА 200 рдЙрддреНрддрд░) тАФ рдПрдХ рд╕реВрдЪрдирд╛, рджреЛрди рдирд╛рд╣реА. рдирд╡реА key рдирд╡реА рд╕реВрдЪрдирд╛ рдмрдирд╡рддреЗ.

тЪая╕П рдиреЗрд╣рдореАрдЪреНрдпрд╛ рдЪреБрдХрд╛

ЁЯПн рдкреНрд░рддреНрдпрдХреНрд╖ рд╡рд╛рдкрд░рд╛рдд

рдХрд░рд╛рд░ repo рдордзреНрдпреЗ рдПрдХрд╛ OpenAPI file рдореНрд╣рдгреВрди рд░рд╛рд╣рддреЛ; server stubs, client code, docs рдЖрдгрд┐ tests рддреНрдпрд╛рдкрд╛рд╕реВрди рдмрдирддрд╛рдд. рд╕реВрдЪрдирд╛ рдлрд▓рдХрд╛рдЪреНрдпрд╛ file рдЪрд╛ рдПрдХ рднрд╛рдЧ:

openapi: 3.1.0
info: { title: Notice board, version: "1.0" }
paths:
  /v1/classes/{classId}/notices:
    post:
      parameters:
        - { name: classId, in: path, required: true, schema: { type: string } }
        - { name: Idempotency-Key, in: header, required: true, schema: { type: string, maxLength: 64 } }
      requestBody:
        content:
          application/json:
            schema: { $ref: "#/components/schemas/NewNotice" }
      responses:
        "201": { description: Created, headers: { Location: { schema: { type: string } } } }
        "403": { description: Not your class }
        "409": { description: Key reused with a different body }
    get:
      parameters:
        - { name: limit, in: query, schema: { type: integer, maximum: 100, default: 20 } }
        - { name: cursor, in: query, schema: { type: string } }
      responses:
        "200": { description: A page of notices, newest first }

Server рдЪрд╛рд▓рд╡рддреЛ рддреА cursor query тАФ index рд╡рд░рдЪрд╛ keyset seek, рдкреНрд░рддреНрдпреЗрдХ page рд╡рд░ рддреЛрдЪ рдЦрд░реНрдЪ:

SELECT id, title, created_at FROM notices
WHERE class_id = '3A' AND (created_at, id) < ($1, $2)   -- values decoded from the cursor
ORDER BY created_at DESC, id DESC LIMIT 20;

On a real account тАФ service рдЪреНрдпрд╛ рдкреБрдвреЗ рдЕрд╕рд▓реЗрд▓рд╛ API рддреБрдордЪрд╛ code рдЪрд╛рд▓рдгреНрдпрд╛рдЖрдзреАрдЪ рдХрд░рд╛рд░ рддрдкрд╛рд╕реВ рд╢рдХрддреЛ, рдЙрджрд╛рд╣рд░рдгрд╛рд░реНрде рддреАрдЪ OpenAPI file Amazon API Gateway рдордзреНрдпреЗ import рдХрд░реВрди:

aws apigateway import-rest-api --body fileb://openapi.yaml

рдЕрдзрд┐рдХ рдЦреЛрд▓рд╛рдд: API рд╢рд╛рд│рд╛ (REST, status codes, auth, pagination, gRPC рдЖрдгрд┐ GraphQL) рдЖрдгрд┐ API Gateway рд╢рд╛рд│рд╛.

ЁЯПн рдкреНрд░рддреНрдпрдХреНрд╖ рд╡рд╛рдкрд░рд╛рдд рд╣реЗ рдХрд╛ рдорд╣рддреНрддреНрд╡рд╛рдЪреЗ: OpenAPI file code рд╕рд╛рд░рдЦреАрдЪ review рдХрд░рд╛, рдЖрдгрд┐ рдПрдЦрд╛рджрд╛ рдмрджрд▓ рдкреНрд░рдХрд╛рд╢рд┐рдд field рдХрд╛рдврддреЛ рдХрд┐рдВрд╡рд╛ rename рдХрд░рддреЛ рддреЗрд╡реНрд╣рд╛ рдЕрдкрдпрд╢реА рд╣реЛрдгрд╛рд░реА рддрдкрд╛рд╕рдгреА CI рдордзреНрдпреЗ рдЪрд╛рд▓рд╡рд╛. рдореЛрдбрдгрд╛рд░реЗ рдмрджрд▓ рд╣рд╛ рдирд┐рд░реНрдгрдп рдЕрд╕рд╛рдпрд▓рд╛ рд╣рд╡рд╛, рдЕрдкрдШрд╛рдд рдХрдзреАрдЪ рдирд╛рд╣реА.

тПня╕П рдкреБрдвреЗ

рдХрд░рд╛рд░ рд╕рд╛рдВрдЧрддреЛ app рдХрд╛рдп рд╡рд┐рдЪрд╛рд░рддреЛ. рдЖрддрд╛: рдЙрддреНрддрд░ рдХреБрдареЗ рд░рд╛рд╣рддреЗ? рдирд┐рдпреЛрдЬрди рдХрд╛рд░реНрдпрд╛рд▓рдп data рдЪреЗ model рддреНрдпрд╛рд▓рд╛ рд╡рд┐рдЪрд╛рд░рд▓реНрдпрд╛ рдЬрд╛рдгрд╛рд▒реНрдпрд╛ рдкреНрд░рд╢реНрдирд╛рдВрд╡рд░реВрди рдмрдирд╡рддреЗ.

git checkout lesson-04-data-model

ЁЯФМ Lesson 03 тАФ API design: the contract first

ЁЯУН You are here: Lesson 03 of 18 ┬╖ Previous: lesson-02-capacity ┬╖ Next: lesson-04-data-model


ЁЯУж What's in this branch

Lessons 01тАУ02, plus the third step of the method: the contract between the app and the server, written before the code. Resources instead of actions, the right status codes, idempotency keys so a retried post is not posted twice, cursor pagination instead of OFFSET, and versioning that never breaks a published field. api() in design/demo.py prints the notice board's contract; pagination_cost() in design/designs.py counts the rows each kind of pagination makes the database walk.

ЁЯзТ Explain like I'm 5

The planning office will not build the hall itself. Builders will тАФ and the carpenters, the electricians and the painters all work at the same time. If each one guesses where the doors are, nothing fits.

So Dipika draws the door plan first ЁЯЪк: this door is for children coming in, that window is for handing out forms, and every door has a sign that says what happens there. Once the door plan is signed, everyone can work in parallel. The app team builds the phone app to the plan; the server team builds the server to the plan.

Two more rules on Dipika's plan:

ЁЯЧ║я╕П Diagram

flowchart LR
    app["ЁЯУ▒ parent / teacher app"]
    subgraph api["ЁЯФМ the contract ┬╖ /v1"]
      p["POST /classes/3A/notices<br/>Idempotency-Key<br/>тЖТ 201 + Location"]
      g["GET /classes/3A/notices<br/>?limit=20&cursor=тАж<br/>тЖТ 200 {items, next_cursor}"]
      f["GET /parents/me/feed<br/>?limit=20"]
    end
    err["тЪая╕П 400 ┬╖ 401 ┬╖ 403 ┬╖ 404 ┬╖ 409 ┬╖ 429"]
    pg["ЁЯУД page 5,000<br/>OFFSET walks 100,000 rows<br/>cursor walks 20"]
    app --> p
    app --> g
    app --> f
    api --> err
    g --> pg

ЁЯЧ║я╕П Drawn version + a lab: https://school-edh.pages.dev/system-design/lesson-diagrams.html#l03

тЭУ What

ЁЯдФ Why

Because the API is the part that is hardest to change later. You can swap a database behind it in a weekend. You cannot make 5 million phones update their app. And the contract carries the design's promises: the idempotency key keeps "a notice is never posted twice" true when networks fail; cursor pagination keeps "read p99 < 200 ms" true on page 5,000; 403 keeps "a parent sees only her children's classes" visible in the contract (lesson 14).

ЁЯФз How (in this repo)

api() in design/demo.py prints the three calls and the errors, then asks pagination_cost(page, page_size, mode) in design/designs.py how many rows each mode walks: page ├Ч page_size for OFFSET, page_size for a cursor. In the snippet you also run a tiny idempotency store тАФ a dictionary from key to answer тАФ and send the same key twice, the way a phone does after a timeout.

ЁЯзк Try it

python3 design/demo.py api
python3 - <<'EOF'
import sys; sys.path.insert(0, "design"); from designs import pagination_cost
for size in (20, 100):
    for page in (1, 500, 50_000):
        print(f"{size:>3} per page, page {page:>6,}: OFFSET {pagination_cost(page, size, 'offset'):>9,} rows ┬╖ cursor {pagination_cost(page, size, 'cursor'):>3}")
seen = {}
def post(key, body):
    if key in seen: return 200, seen[key], "replayed"
    seen[key] = f"notice-{len(seen) + 1}"; return 201, seen[key], "created"
print(post("k-7f3a", {"title": "Trip"}))
print(post("k-7f3a", {"title": "Trip"}))   # the phone retried after a timeout
print(post("k-9b21", {"title": "Exam"}))
EOF

тЬЕ Verify тАФ what you should see

api prints:

тФАтФА the contract first тАФ resources, not actions:
   POST /classes/3A/notices          body {title, text, urgent} ┬╖ header Idempotency-Key тЖТ 201 + Location
   GET  /classes/3A/notices?limit=20&cursor=eyJpZCI6OTB9 тЖТ 200 {items, next_cursor}
   GET  /parents/me/feed?limit=20   тЖТ the notices of all my children's classes
   errors: 400 bad input ┬╖ 401 who are you ┬╖ 403 not your class ┬╖ 404 ┬╖ 409 key reused with a different body ┬╖ 429 slow down
тФАтФА pagination: rows the database walks to show page N (20 per page)
   page     1: OFFSET walks      20 rows ┬╖ a cursor walks  20
   page    50: OFFSET walks   1,000 rows ┬╖ a cursor walks  20
   page  5000: OFFSET walks 100,000 rows ┬╖ a cursor walks  20
   version in the path (/v1) or a header; never break a published field тАФ add new ones

Your snippet prints:

 20 per page, page      1: OFFSET        20 rows ┬╖ cursor  20
 20 per page, page    500: OFFSET    10,000 rows ┬╖ cursor  20
 20 per page, page 50,000: OFFSET 1,000,000 rows ┬╖ cursor  20
100 per page, page      1: OFFSET       100 rows ┬╖ cursor 100
100 per page, page    500: OFFSET    50,000 rows ┬╖ cursor 100
100 per page, page 50,000: OFFSET 5,000,000 rows ┬╖ cursor 100
(201, 'notice-1', 'created')
(200, 'notice-1', 'replayed')
(201, 'notice-2', 'created')

ЁЯПБ What you just proved

OFFSET cost grows with the page number тАФ page 50,000 walks a million rows to show 20. A cursor costs the same on page 1 and page 50,000. And the same Idempotency-Key sent twice gives back the same notice (notice-1, answered 200 the second time) тАФ one notice, not two. A new key makes a new notice.

тЪая╕П Common mistakes

ЁЯПн In production

The contract lives in the repo as an OpenAPI file; server stubs, client code, docs and tests are made from it. Part of the notice board's file:

openapi: 3.1.0
info: { title: Notice board, version: "1.0" }
paths:
  /v1/classes/{classId}/notices:
    post:
      parameters:
        - { name: classId, in: path, required: true, schema: { type: string } }
        - { name: Idempotency-Key, in: header, required: true, schema: { type: string, maxLength: 64 } }
      requestBody:
        content:
          application/json:
            schema: { $ref: "#/components/schemas/NewNotice" }
      responses:
        "201": { description: Created, headers: { Location: { schema: { type: string } } } }
        "403": { description: Not your class }
        "409": { description: Key reused with a different body }
    get:
      parameters:
        - { name: limit, in: query, schema: { type: integer, maximum: 100, default: 20 } }
        - { name: cursor, in: query, schema: { type: string } }
      responses:
        "200": { description: A page of notices, newest first }

The cursor query the server runs тАФ a keyset seek on an index, the same cost on every page:

SELECT id, title, created_at FROM notices
WHERE class_id = '3A' AND (created_at, id) < ($1, $2)   -- values decoded from the cursor
ORDER BY created_at DESC, id DESC LIMIT 20;

On a real account тАФ an API in front of the service can check the contract before your code runs, for example by importing the same OpenAPI file into Amazon API Gateway:

aws apigateway import-rest-api --body fileb://openapi.yaml

Go deeper: the API school (REST, status codes, auth, pagination, gRPC and GraphQL) and the API Gateway school.

ЁЯПн Why this matters in production: review the OpenAPI file like code, and run a check in CI that fails when a change removes or renames a published field. Breaking changes should be a decision, never an accident.

тПня╕П Next

The contract says what the app asks. Now: where does the answer live? The planning office models the data from the questions it will be asked.

git checkout lesson-04-data-model
тЖР PreviouscapacityNext тЖТdata model

This page is the lesson's README from the lesson-03-api-design branch, shown here so the whole School stays on one site. Code files open on GitHub at the same branch.