Software Engineering·July 21, 2026·9 min read

API Design

How to design APIs that are predictable, consistent, and easy to build on — naming, versioning, errors, pagination, and more.

apirestgraphqlengineering

An API is a contract between your app and everything that calls it — other services, your own frontend, third-party developers. A well-designed API is predictable and consistent: a developer who has used one endpoint should be able to guess how the others work. If Cartara flagged this in your diff, you likely added or changed an endpoint in a way that could confuse callers or break existing integrations.

What makes an API "good"

Good API design comes down to one word: consistency. The cost of getting it wrong is high because APIs are contracts — once someone depends on an endpoint, changing it breaks their code. You live with early decisions for a long time, so it's worth thinking deliberately before you ship.

How to design RESTful endpoints

REST is organized around resources (nouns), acted on by HTTP methods (verbs). The method already supplies the verb, so your URL should be a noun:

  • Use nouns, not verbs in paths: GET /users/42, not GET /getUser?id=42
  • Use plural nouns: /users, /orders, /products
  • Nest to show relationships, but not too deep: /users/42/orders is clear; anything past two levels is usually a sign to flatten
  • Let HTTP methods carry meaning: GET is safe (no side effects), PUT and DELETE are idempotent, POST creates or triggers
  • Keep status codes honest: 2xx success, 4xx client error, 5xx server error. Never return 200 OK with an error in the body

Versioning your API

Because an API is a contract, you need a way to evolve it without breaking callers. The pragmatic default is URL versioning: /v1/users. It's visible, easy to test, and easy to route.

The deeper principle is backward compatibility: prefer additive changes — new optional fields, new endpoints — that don't break existing clients. Adding a field is safe. Removing one, renaming one, or changing its type is a breaking change that needs a version bump and a deprecation window.

Pagination: never return an unbounded list

GET /orders on a large dataset will time out or blow up memory. You need pagination. Two approaches:

  • Offset/limit (?limit=20&offset=40) — simple, allows jumping to any page, but slow on large offsets and can skip rows if data changes mid-browse
  • Cursor-based (?limit=20&after=<cursor>) — the client passes a pointer to the last item seen. Fast, stable, and used by most large APIs (Stripe, GitHub)

Cursor pagination is the better default for anything that scales. Return the next cursor and a "has more" flag in a consistent place in every response.

Designing good error responses

Error responses are part of your API surface and deserve as much care as success responses. A good error is machine-readable and human-helpful:

```json
{
"error": {
"code": "insufficient_funds",
"message": "The card has insufficient funds.",
"field": "payment_method",
"request_id": "req_a1b2c3"
}
}
```

Key points:

  • Use a stable code field that clients can branch on — don't make them string-match your human message
  • Include a request_id so developers can find the failure in your logs
  • Keep the shape consistent across every endpoint
  • Never expose stack traces or SQL in production error bodies

Idempotency: handling retries safely

An operation is idempotent if doing it twice has the same effect as doing it once. GET, PUT, and DELETE are naturally idempotent. POST is not — submitting "create payment" twice creates two payments.

Networks are unreliable, so clients retry. The standard solution is an idempotency key: the client sends a unique key with the request, and the server returns the original result instead of acting again on a duplicate. This is essential for anything involving money or side effects.

Rate limiting: protect your API and communicate clearly

Without rate limiting, a single bad actor can exhaust your resources. Good rate limiting is communicative, not just protective:

  • Return 429 Too Many Requests when a client exceeds their limit
  • Include headers showing their limit, remaining quota, and reset time
  • Return a Retry-After header so clients know how long to wait

What You'll See in Your Code

A common pattern Cartara flags: an endpoint that returns all records without pagination.

```javascript
// Before: will blow up on large datasets
app.get('/api/orders', async (req, res) => {
const orders = await db.orders.findMany()
res.json(orders)
})

// After: cursor-based pagination
app.get('/api/orders', async (req, res) => {
const { after, limit = 20 } = req.query
const orders = await db.orders.findMany({
take: parseInt(limit) + 1,
cursor: after ? { id: after } : undefined,
orderBy: { createdAt: 'desc' }
})
const hasMore = orders.length > limit
res.json({
data: orders.slice(0, limit),
nextCursor: hasMore ? orders[limit - 1].id : null,
hasMore
})
})
```

Another common flag: inconsistent error shapes.

```javascript
// Before: different error formats across endpoints
res.status(400).json({ message: 'Email required' }) // endpoint A
res.status(400).json({ error: 'Invalid input', field: 'email' }) // endpoint B

// After: consistent shape everywhere
res.status(400).json({
error: {
code: 'validation_error',
message: 'Email is required.',
field: 'email',
request_id: req.id
}
})
```

REST vs GraphQL vs gRPC vs tRPC

StyleBest forTrade-off
RESTPublic APIs, broad compatibilityCan over/under-fetch
GraphQLComplex data graphs, many frontend teamsServer complexity, harder caching
gRPCInternal service-to-service, high-volumeNot browser-friendly
tRPCFull-stack TypeScript monoreposTypeScript-only

A sensible default: REST (or tRPC in a TypeScript monorepo) for your app. Don't adopt GraphQL's complexity for a simple CRUD app.

Related concepts

Understanding APIs
An API lets two pieces of software talk to each other. This guide explains REST APIs, webhooks, authentication, and status codes in plain English — with real examples.
Database Fundamentals
A database stores and organises your app's data. This guide explains SQL vs NoSQL, when to use each, core concepts like indexes and transactions, and how to get started.
Message Queues and Async Processing
Message queues let your app offload slow work to the background so users get fast responses. This guide explains queues, pub/sub, workers, and key patterns like idempotency and dead letter queues.

Turn shipping into understanding

Cartara measures what your team actually learns from every AI coding session.

Join the waitlist