Problem RestatementProblem
Microsoft asked: design REST APIs (and the backend) for a service that stores JSON documents (small files), organized in folders. Clients create, read, update, partially update, delete and list documents. Key topics: resource design, metadata, concurrency control when two clients edit the same document, and good error handling.
Resources and Endpoints
POST /v1/folders { name, parent_id? } → 201 folder
GET /v1/folders/{fid}/documents?cursor=&limit=50&sort=updated_at → 200 { items, next_cursor }
POST /v1/folders/{fid}/documents { name, content: {...} } → 201, Location, ETag
GET /v1/documents/{id} → 200 body = JSON, ETag: "v7"
PUT /v1/documents/{id} If-Match: "v7" { content } → 200, ETag: "v8" | 412
PATCH /v1/documents/{id} If-Match: "v7" (JSON Merge Patch or JSON Patch) → 200 | 412
DELETE /v1/documents/{id} If-Match: "v8" → 204 | 412
GET /v1/documents/{id}/metadata → { name, size, owner, created_at, updated_at, version }
GET /v1/documents/{id}/versions/{n} → an older version (optional)Status codes: 201 created, 200 ok, 204 no content, 400 invalid JSON or schema error, 401/403 auth, 404 not found, 409 name conflict in the folder, 412 precondition failed (stale ETag), 413 too large, 429 rate limited.
Deep Dive — Two people saving the same documentDeep dive
Two users open version 7 and both press save. Without protection the second save silently erases the first — the lost update, and the reason this question is asked.
PUT overwrites whatever is there
The client sends the full document and the server stores it.
%%{init: {"look":"handDrawn","handDrawnSeed":7,"theme":"base","fontFamily":"Virgil, \"Segoe Print\", \"Comic Sans MS\", cursive","themeVariables":{"fontFamily":"Virgil, \"Segoe Print\", \"Comic Sans MS\", cursive","fontSize":"16px","primaryColor":"#fff4e6","primaryBorderColor":"#1e1e1e","primaryTextColor":"#1e1e1e","secondaryColor":"#e7f5ff","tertiaryColor":"#ebfbee","lineColor":"#1e1e1e","textColor":"#1e1e1e","mainBkg":"#fff4e6","nodeBorder":"#1e1e1e","clusterBkg":"#f8f9fa","edgeLabelBackground":"#ffffff","classText":"#1e1e1e"}}}%%
sequenceDiagram
participant A as User A
participant S as Server
participant B as User B
A->>S: GET doc - version 7
B->>S: GET doc - version 7
A->>S: PUT - adds a section
B->>S: PUT - fixes a typo, based on v7
Note over S: A's section is gone, and nobody is toldBoth writes succeed and one person's work disappears without an error. The user who lost it usually discovers it much later, with no way to tell what happened.
Compare a last-modified timestamp
Store last_modified and reject a write whose timestamp is older than the stored one.
It catches the obvious case and it is fragile for two reasons. Timestamp resolution: two saves inside the same millisecond compare equal and one is lost anyway. And clock source: if the client supplies the time, clock skew decides who wins, which is not a decision anyone chose.
A version, exposed as an ETag, enforced by a conditional write
%%{init: {"look":"handDrawn","handDrawnSeed":7,"theme":"base","fontFamily":"Virgil, \"Segoe Print\", \"Comic Sans MS\", cursive","themeVariables":{"fontFamily":"Virgil, \"Segoe Print\", \"Comic Sans MS\", cursive","fontSize":"16px","primaryColor":"#fff4e6","primaryBorderColor":"#1e1e1e","primaryTextColor":"#1e1e1e","secondaryColor":"#e7f5ff","tertiaryColor":"#ebfbee","lineColor":"#1e1e1e","textColor":"#1e1e1e","mainBkg":"#fff4e6","nodeBorder":"#1e1e1e","clusterBkg":"#f8f9fa","edgeLabelBackground":"#ffffff","classText":"#1e1e1e"}}}%%
flowchart LR
G["GET /docs/42"] --> E["200 + ETag: \"7\""]
E --> EDIT["Client edits"]
EDIT --> P["PUT with If-Match: \"7\""]
P --> COND["UPDATE ... SET version = 8 WHERE id = 42 AND version = 7"]
COND -->|"1 row"| OK["200 - new ETag \"8\""]
COND -->|"0 rows"| C412["412 Precondition Failed"]
C412 --> REF["Client re-fetches, merges or shows a conflict, retries"]- The version is an integer the server owns, so there is no resolution problem and no clock involved.
- The check and the write are one statement.
WHERE version = 7means the comparison happens inside the update, so two concurrent saves cannot both pass it. - 412 is the useful answer. It tells the client precisely what happened — someone else changed this — so it can re-fetch and merge rather than guessing from a generic error.
PATCHuses the same ETag check with a smaller payload, which matters when documents are large and the edit is one field.
Make If-Match required for updates rather than optional. An optional precondition is one the first client to skip it will skip, and then the guarantee only holds for the well-behaved callers — which is not a guarantee.
Backend
%%{init: {"look":"handDrawn","handDrawnSeed":7,"theme":"base","fontFamily":"Virgil, \"Segoe Print\", \"Comic Sans MS\", cursive","themeVariables":{"fontFamily":"Virgil, \"Segoe Print\", \"Comic Sans MS\", cursive","fontSize":"16px","primaryColor":"#fff4e6","primaryBorderColor":"#1e1e1e","primaryTextColor":"#1e1e1e","secondaryColor":"#e7f5ff","tertiaryColor":"#ebfbee","lineColor":"#1e1e1e","textColor":"#1e1e1e","mainBkg":"#fff4e6","nodeBorder":"#1e1e1e","clusterBkg":"#f8f9fa","edgeLabelBackground":"#ffffff","classText":"#1e1e1e"}}}%%
flowchart LR
C["Clients"] --> API["Documents API - auth, validation"]
API --> META[("Metadata DB - folders, docs, versions, ACLs")]
API --> STORE[("Content store - document DB or object storage")]
API --> SCH[("JSON schemas (optional)")]- Metadata (name, folder, owner, size, version, timestamps, permissions) in a relational DB. There's a unique constraint on (folder_id, name), which gives the 409 on duplicate names.
- Content: small documents (< 1 MB) in a document database (e.g., stored as JSONB), and large ones in object storage, referenced by key. Old versions are kept for history, with retention limits.
- Validation: check that the body is valid JSON, and optionally validate against a JSON Schema registered for the folder.
- Listing: cursor pagination (sorted by updated_at + id), with filters by name prefix or date.
Extras
- Permissions: folder-level ACLs inherited by documents (owner, editor, viewer). Check them on every call.
- Large documents: max body size (413). For big ones, support upload via a pre-signed URL.
- Caching: GET with
If-None-Match: "v8"→ 304 Not Modified if unchanged, which saves bandwidth. - Soft delete + trash, so accidental deletes can be restored.
Wrap-UpWrap-up
Model folders and documents as REST resources with clear verbs and status codes, and return the document's version as an ETag. Require If-Match on PUT, PATCH and DELETE, with conditional writes that return 412 on conflicts (no lost updates). Keep metadata (with a unique folder/name constraint) in a relational DB and content in a document store or object storage with version history, validate JSON (optionally against schemas), paginate with cursors, and support 304 caching.