API Design: REST, GraphQL, and gRPC
An API (Application Programming Interface) is the contract between a client and a server. It defines what data you can request, how to format that request, and what the server sends back. Once clients start depending on that contract — mobile apps, third-party integrations, or other microservices — changing it without breaking them becomes expensive.
This makes API design one of the most consequential early decisions in a system. A poorly designed API accumulates broken clients, awkward workarounds, and mounting technical debt. A well-designed API ages gracefully: it supports new clients without disrupting old ones, returns only the data each client actually needs, and handles large datasets without straining the database.
AI agents will generate API endpoints readily — but they tend to pick the simplest pattern available without considering how the API will evolve or behave at scale. Understanding the trade-offs between REST, GraphQL, and gRPC, knowing how to version an API safely, and choosing the right pagination strategy are the constraints you should set before prompting, so the AI builds the contract you actually need.
Choosing an API Style#
The three dominant API styles in production are REST, GraphQL, and gRPC. Each solves a different problem and fits a different context.
REST, GraphQL, and gRPC
Three API styles, each optimized for a different use case. REST is the universal default — simple, well-understood, and supported everywhere. GraphQL solves the problem of over-fetching and under-fetching for data-heavy clients. gRPC delivers high performance for internal microservice communication.
The Over-Fetching and Under-Fetching Problem#
This is the central motivation for GraphQL and the most common complaint about REST at scale. Consider a mobile app rendering a user profile screen that needs: the user's name, avatar, follower count, and their three most recent post titles.
With REST, each call to /users/42 also returns the user's email, bio, address, phone, and a dozen other fields the profile screen never uses — that is over-fetching. And a single GET /users/42 doesn't include follower count or recent posts, so the client must fire additional requests to assemble one screen — that is under-fetching. Even if those extra requests run in parallel, you're still making multiple network round-trips per screen render. On a mobile device on a slow network, this adds up to noticeable latency.
GraphQL solves both problems: the client sends one request specifying exactly name, avatar, followerCount, and recentPosts { title }. The server resolves those fields — often in parallel — and returns only what was asked for.
The DataLoader caveat: The N+1 query problem is the most common GraphQL performance bug AI agents introduce. If you have a list of 20 posts and each post's resolver fetches the author separately, that's 21 database queries (1 for the list + 20 for the authors). The fix is a DataLoader: it collects all pending author lookups and batches them into a single SELECT * FROM users WHERE id IN (...) query. Ask your AI to use DataLoaders for any resolver that fetches associated records.
Quick Comparison#
| REST | GraphQL | gRPC | |
|---|---|---|---|
| Protocol | HTTP/1.1 or HTTP/2 | HTTP/1.1 or HTTP/2 | HTTP/2 only |
| Data format | JSON (human-readable) | JSON (human-readable) | Protobuf (binary, compact) |
| Endpoints | Many — one per resource | One — /graphql | Many — defined in .proto |
| Type system | None (by default) | GraphQL schema | Protocol Buffers (.proto) |
| Browser support | Native | Native | Requires gRPC-Web proxy |
| Caching | HTTP caching works naturally (GET) | Requires custom cache logic | Application-level only |
| Best for | Public APIs, CRUD services | Complex client data needs | Internal microservices |
| AI default? | Yes — AI generates REST by default | No — requires explicit instruction | No — requires explicit instruction |
The practical rule: Start with REST. It is the correct choice for the vast majority of web APIs. Reach for GraphQL only when different clients (web, iOS, Android) need substantially different shapes of the same data, or when the number of REST round-trips per screen is becoming a real performance problem. Reach for gRPC only for internal service communication where throughput and strict contracts matter more than browser compatibility.
API Versioning#
APIs change. You fix bugs, add required fields, rename properties, or change behavior as your product evolves. But clients that depend on the current version don't update immediately — some never do. API versioning is how you evolve your API without silently breaking the clients that already depend on it.
The core principle: never make a breaking change to an existing version. A breaking change is any modification that causes a working client to fail — removing a field, renaming a property, changing a field's type, or altering the meaning of an existing value. Instead, introduce a new version with the changes, keep the old version running, and give clients a clear migration path.
API Versioning Strategies
The three main strategies for versioning a REST API differ in where the version information lives: in the URL path, in an HTTP header, or as a query parameter. Each makes a different trade-off between simplicity, cacheability, and URL cleanliness.
| Strategy | Version Location | Easy to Test in Browser | HTTP Cache-Friendly | Best For |
|---|---|---|---|---|
| URI Path | /api/v1/users | Yes — visible in URL | Yes — each version is a different URL | Public APIs, external integrations (recommended default) |
| Header | Api-Version: 2 header | No — requires a tool like curl or Postman | Only with Vary header configured | Internal APIs where clients are controlled |
| Query Param | /api/users?version=2 | Yes — visible in URL | Yes — treated as part of the URL | Simple internal tools; not recommended for public APIs |
What counts as a breaking change? Removing a field, renaming a field, changing a field's type (string → integer), making a previously optional field required, or changing the meaning of an existing value. Any of these can cause clients built against the old contract to fail or behave incorrectly.
What is not a breaking change? Adding a new optional field to a response, adding a new optional query parameter, or adding a new endpoint. Clients that don't know about the new field will simply ignore it — this is by design.
Pagination#
Any API that returns a list of records must paginate. Without pagination, a single request to /api/posts could return millions of rows — exhausting the database, saturating the network, and crashing the client. Pagination limits responses to a fixed number of items at a time and gives the client a way to fetch the next batch.
There are two fundamentally different approaches: offset-based and cursor-based. They behave identically for the first few pages, but diverge significantly at scale.
Offset vs. Cursor Pagination
Offset pagination counts rows from the start of the table on every request. Cursor pagination uses a stable marker — typically the last item's ID — to jump directly to the starting point. The difference is invisible with small datasets and critical with large ones.
How Each Approach Works#
Offset pagination maps directly to SQL's LIMIT and OFFSET clauses:
-- Page 1: items 1-20
SELECT * FROM posts ORDER BY created_at ASC LIMIT 20 OFFSET 0;
-- Page 500: items 9,981-10,000
SELECT * FROM posts ORDER BY created_at ASC LIMIT 20 OFFSET 9980;
The database must scan through all 9,980 rows before the requested page in order to count how many to skip. There is no shortcut — even with an index on created_at, PostgreSQL must traverse 9,980 index entries before returning the 20 you actually want. This is why page 1 returns in 2ms and page 500 might take 200ms on a large table.
Cursor pagination replaces the count-based offset with a positional anchor — the ID (or timestamp) of the last item the client saw:
-- Page 1: items 1–20 (no cursor)
SELECT * FROM posts ORDER BY id ASC LIMIT 20;
-- Client stores cursor = last returned id (e.g., 20)
-- Page 2: items 21–40 (cursor = 20)
SELECT * FROM posts WHERE id > 20 ORDER BY id ASC LIMIT 20;
-- Client stores cursor = 40
-- Page 500: items 9,981–10,000 (cursor = 9,980)
SELECT * FROM posts WHERE id > 9980 ORDER BY id ASC LIMIT 20;
-- Uses the index on id to seek to > 9980 and read the next 20 rows
The WHERE id > 9980 clause lets the database use the index directly — it jumps straight to id 9981 without scanning anything before it. The query cost is the same whether you are fetching page 1 or page 10,000.
| Offset Pagination | Cursor Pagination | |
|---|---|---|
| SQL clause | LIMIT 20 OFFSET 9980 | WHERE id > cursor LIMIT 20 |
| Query cost at page N | O(n) — grows with depth | O(1) — constant at any depth |
| Jump to arbitrary page | Yes — ?page=47 works | No — forward/backward navigation only |
| Safe with live data inserts | No — items skip or duplicate | Yes — cursor holds exact position |
| Used by | Admin dashboards, small static lists | Twitter, GitHub, Instagram, Stripe, Facebook |
| AI default | Yes — simpler to generate | No — must be explicitly requested |
A note on cursor encoding: In production APIs, cursors are typically base64-encoded opaque strings rather than raw IDs. The client treats the cursor as a black box — it stores it and passes it back on the next request without trying to parse or interpret it. This lets the server change the internal cursor structure (for example, switching from a simple ID to a composite timestamp + ID for more stable ordering) without breaking any clients. Stripe, GitHub, and Facebook all use opaque encoded cursors in their pagination APIs.
Prompting AI Agents for API Design#
AI agents generate working API endpoints by default — but they make specific, predictable choices that may not match your production requirements.
| Context | Include in Your Prompt | What AI Gets Wrong Without It |
|---|---|---|
| API style | "Build this as a REST API with URI versioning at /api/v1/..." or "Use GraphQL with DataLoader for author lookups" | AI defaults to REST without versioning. If you want GraphQL or gRPC, you must say so explicitly. |
| Pagination | "Use cursor-based pagination. The response should include a nextCursor field. Use WHERE id > :cursor LIMIT :pageSize in the query." | AI generates offset pagination with LIMIT/OFFSET by default. It will not switch to cursor pagination unless explicitly instructed. |
| Versioning | "All routes must be prefixed with /api/v1/. Do not make breaking changes to existing fields — add new fields only as optional additions." | AI generates unversioned endpoints and makes breaking changes without concern for existing clients. |
| Breaking changes | "This endpoint is live. You may add new optional fields. You may not rename, remove, or change the type of any existing field." | AI will happily rename a field for clarity without realizing that renames are breaking changes for clients that parse the existing field name. |
| DataLoader | "Use DataLoader to batch author lookups in the posts resolver. Never fire one query per post." | AI generates resolvers that fire one SQL query per item in the list — the classic N+1 bug that is invisible in development and catastrophic in production. |
Summary#
| Concept | The Practical Rule |
|---|---|
| REST | The correct default for most APIs. Simple, universal, and well-supported everywhere. Use it unless you have a specific reason not to. |
| GraphQL | Solves over-fetching and under-fetching for clients with varied, complex data needs. Worth the added complexity for mobile apps and data-heavy frontends — not for simple CRUD services. |
| gRPC | Best for internal microservice communication where throughput and strict type contracts matter. Not for browser-facing or public APIs without an additional proxy layer. |
| URI versioning | The industry default for API versioning. Embed the version in the URL path (/api/v1/...). Never make breaking changes to an existing version — introduce a new one instead. |
| Cursor pagination | The only safe pagination strategy for large or frequently updated datasets. AI defaults to offset pagination; specify cursor pagination explicitly in your prompt. |
| AI defaults | AI generates REST endpoints without versioning and with offset pagination. Specify your API style, versioning convention, and pagination strategy upfront to get production-appropriate code. |
Sources:
- REST vs. GraphQL vs. gRPC — Which API to Choose? | Baeldung
- When to Use REST vs. gRPC vs. GraphQL | Kong
- GraphQL vs. gRPC vs. REST: Choosing the right API | LogRocket
- API Versioning Best Practices | Gravitee
- API Versioning: URL vs Header vs Media Type | Lonti
- Understanding Cursor Pagination and Why It's So Fast | Milan Jovanovic
- A Developer's Guide to API Pagination: Offset vs. Cursor-Based | Gusto
- Cursor pagination: how it works and its pros and cons | Merge.dev