intermediate

Case Study: Search API

Product-wide search over documents, projects, and people: one query box, filters, sorting, and paginated results from a live, constantly-changing index.

Search looks like GET /search?q= and hides two contract problems that plain list endpoints never face. First, cost is caller-controlled: a filter, a fuzzy term, and a deep page multiply into queries that are 1000× more expensive than the median, so the contract needs complexity limits the way an upload API needs size limits (Large Requests and Documented Limits). Second, the result set is a moving target: documents are created, edited, and re-ranked while the user pages through, and the contract must say what pagination means over data that won't hold still (Search Is a Different Contract Than Filtering). The design below answers both the same way: promise less, explicitly — bounded depth, snapshot-consistent pages, best-effort counts — rather than implying guarantees the index cannot keep.

Consumers

Web app search page

Fast (<200ms p95) first page, facet counts for the filter sidebar, highlighted snippets, next-page on scroll.

Global quick-search (cmd-K)

Top 5 hits across types on every keystroke — extreme rate, tiny responses, relevance over completeness.

Third-party integrations

Programmatic search over the public API with the same permission trimming a user would get — and no way to run denial-of-service queries.

Requirements

  • Full-text query with typo tolerance across heterogeneous types (documents, projects, people), filterable by type, owner, date, tags.
  • Results are permission-trimmed: nobody ever sees a hit they couldn't open — not even its title.
  • Sort by relevance (default), recency, or name; pagination is stable enough that scrolling never shows the same hit twice.
  • Facet counts for the sidebar in the same round trip as results.
  • One caller's pathological query cannot degrade search for everyone else.
  • Freshness is honest: the docs state how quickly an edit becomes searchable (target: <10s), because "immediately" would be a lie.

Resources

SearchResult

A projection, not an entity: id, type, title, snippet with highlights, score, and a `url` to the real resource. Keeping results thin means the index never becomes a second copy of every API's response schema that must evolve in lockstep.

Query (as contract)

Not a stored resource in V1, but the request schema is versioned and validated like one: allowlisted filter fields, bounded term counts, typed values — because the query language *is* the attack and cost surface ([[filtering]]).

Facet

Aggregated counts per filter dimension, returned alongside results. Modeled explicitly (requested via `facets=owner,type`) because each facet costs an aggregation — callers ask for what they render, not everything.

SavedSearch

Deferred to V2 deliberately, but named in V1 design: knowing it's coming is why the query schema is a serializable object rather than an ad-hoc parameter soup.

Operations

OperationPurposeDesign notes
GET /searchThe main event: `q`, `type`, `filter.*`, `sort`, `cursor`, `limit`, `facets`.GET, not POST: search is safe and cacheable, and shareable result URLs are a product feature. The documented URL-length ceiling (2KB) is also the first complexity limit in disguise (GET: The Promise of Safety).
GET /search/suggestPrefix completions for the quick-search box.A separate endpoint, not a ?mode= flag: 20× the rate, 1/20th the work, different caching (30s TTL is fine), different rate budget. One endpoint serving both profiles would need the union of their guarantees.
GET /search?cursor=…Continue a result set.The cursor encodes query hash, sort position, *and an index snapshot marker*: continuation reads the same index generation, so paging is duplicate-free even as documents churn. Expires in 5 minutes — a deliberate promise-narrowing that makes stability affordable (Cursor Pagination: An Opaque Bookmark, Not a Position).
GET /search/countExact count for a query, when someone truly needs it.Split from /search because exact counting is often costlier than the first page. The main response carries total: {value, relation: "eq" | "gte"} — honest approximation (≥10,000) by default; the expensive precision is opt-in and rate-limited separately.
POST /search/queries/validateDry-run a query: is it legal, and what would it cost?Returns the computed complexity score and limit without executing. Exists for integrators: the alternative is discovering QUERY_TOO_COMPLEX in production (Documentation Is Part of the Contract as a runtime service).
GET /search/fieldsMachine-readable catalog of filterable/sortable fields per type.The allowlist, published: integrators discover capabilities instead of probing, and deprecating a field starts by marking it here.

Error contract

CodeStatusWhenRetryable
INVALID_QUERY400Unparseable syntax, unknown filter field, wrong value type. `details` names the offending part — search queries are user-typed, so error quality is UX.no
QUERY_TOO_COMPLEX422Complexity score over budget: too many terms/clauses, wildcard-heavy patterns, too many facets. Body carries `score`, `limit`, and which components cost the most — actionable, not just "no".no
PAGE_DEPTH_EXCEEDED422Paging past result 5,000. Deep paging costs grow with depth and no human reads page 250 — bulk consumers are pointed at the export flow instead ([[unbounded-collections]]).no
CURSOR_EXPIRED410A cursor older than 5 minutes, or spanning an index rebuild. `410` (not `400`) tells the client this cursor *was* valid: re-run the query to get a fresh snapshot.no
RATE_LIMITED429Per-caller budget exceeded — suggest traffic and full search are budgeted separately so cmd-K can never starve the search page.after delay
INDEX_UNAVAILABLE503The search cluster is degraded. `Retry-After` set; the docs tell UI clients to degrade to recent-items rather than hard-fail the whole page.after delay

Decision log

Decision → reason → alternative → trade-off. The alternative is part of the record.

Query complexity scoring with a hard budget, enforced before execution.
Reason · Search cost is caller-shaped: *a* across all types with 10 facets can be 1000× the median query. Post-hoc timeouts kill the query *after* it hurt the cluster; admission control rejects it before (Large Requests and Documented Limits).
Alternative · Per-query timeouts plus caller rate limits alone.
Trade-off · The scoring model needs tuning and versioning, and legitimate power users hit the ceiling — softened by the validate endpoint and a raised budget tier for vetted integrations.
Snapshot-pinned cursors with a 5-minute TTL, instead of live-index paging or offset.
Reason · Offset over a re-ranking index shows duplicates within seconds (a document's score changes and it moves pages). Pinning continuation to an index generation makes paging exact; the short TTL is what makes pinning cheap enough to offer (Pagination: Choosing How Lists End).
Alternative · Offset with documented "results may shift" hand-waving.
Trade-off · No jumping to page N, and idle-tab continuations die at 5 minutes — the UI treats expiry as "re-run and start fresh", which users read as a refresh, not an error.
Approximate totals by default (`relation: "gte"`), exact counts as a separate opt-in endpoint.
Reason · Exact counting scans everything the query matches — often costlier than returning ten hits. "About 10,000+" serves the UI need (is it worth refining my query?) at 1% of the cost.
Alternative · Always-exact totals.
Trade-off · Pagination UIs can't render "page 3 of 41" — accepted, because with a moving index that number was fiction anyway.
Permission trimming at query time inside the engine, via ACL terms indexed with each document.
Reason · Filtering after retrieval leaks through counts and pagination (page says "10 results", shows 3) and wastes the budget on hits the caller can't see. Security invariants belong in the query, not in post-processing (Authorization Design in the Contract).
Alternative · Over-fetch and filter in the API layer.
Trade-off · Permission changes must propagate into the index, adding a freshness dimension — bounded by the same <10s pipeline promise and re-checked at open-time by the target resource anyway.
Search (`/search?q=`) and filtering (`/documents?owner=`) remain distinct contracts.
Reason · Filtering is deterministic and exact; search is ranked, fuzzy, and approximate. Merging them makes list endpoints inherit relevance semantics they can't honor — and every list consumer pay index freshness lag (Search Is a Different Contract Than Filtering).
Alternative · One unified query endpoint for everything.
Trade-off · Two query syntaxes for integrators to learn; the fields catalog keeps the filter vocabulary shared between them so the seam stays small.

How it evolves

  • New searchable type (comments) is additive by construction: results carry type, and V1 docs required clients to skip unknown types — new types appear only when a request opts in via type= or the client declares support (Enum Evolution: The New Value That Broke Old Clients applied to result payloads).
  • Semantic/vector search lands as mode: "hybrid" | "lexical" (default lexical) plus a rank_signals debug field — ranking changes ship behind an explicit switch first, because silently changing relevance is a behavioral break dashboards notice even if schemas don't.
  • Saved searches and alerts promote the already-serializable query object into a stored SavedSearch resource with CRUD and a notification hook — the V1 decision to keep queries structured pays off here.
  • Bulk export for integrators arrives as an async job (POST /search/exports202, job id, S3-style result delivery) rather than lifting the 5,000-result depth cap — deep paging pressure is routed to the pattern built for it (The Async Job Pattern).

Lessons behind this design