home / skills / martinffx / claude-code-atelier / api-design

api-design skill

/plugins/atelier-typescript/skills/api-design

This skill guides REST API design with consistent endpoints, versioning, error handling, pagination, and structure for maintainable, framework-agnostic APIs.

npx playbooks add skill martinffx/claude-code-atelier --skill api-design

Review the files below or copy the command above to add this skill to your agents.

Files (2)
SKILL.md
10.0 KB
---
name: api-design
description: REST API design patterns. Use when designing endpoints, error responses, pagination, versioning, or API structure. Framework-agnostic principles for building consistent, maintainable APIs.
user-invocable: false
---

# API Design Patterns

Best practices for designing REST APIs with consistent structure, error handling, and resource patterns.

## Additional References

- [references/error-responses.md](./references/error-responses.md) - Detailed error handling examples

## Resource Naming

Use consistent, predictable URL patterns:

```
# Collection resources (plural nouns)
GET    /api/v1/users              # List users
POST   /api/v1/users              # Create user
GET    /api/v1/users/:id          # Get user
PUT    /api/v1/users/:id          # Update user (full)
PATCH  /api/v1/users/:id          # Update user (partial)
DELETE /api/v1/users/:id          # Delete user

# Nested resources
GET    /api/v1/users/:userId/posts          # List user's posts
POST   /api/v1/users/:userId/posts          # Create post for user
GET    /api/v1/users/:userId/posts/:postId  # Get specific post

# Actions (use verbs sparingly)
POST   /api/v1/users/:id/activate          # Activate user
POST   /api/v1/posts/:id/publish           # Publish post
POST   /api/v1/invoices/:id/send           # Send invoice
```

### Guidelines

- Use plural nouns for collections (`/users`, not `/user`)
- Use lowercase with hyphens for multi-word resources (`/ledger-accounts`)
- Avoid deep nesting (max 2 levels: `/users/:id/posts/:id`)
- Use query parameters for filtering, sorting, pagination
- Use verbs only for actions that don't fit CRUD (activate, publish, send)

## API Versioning

Version APIs in the URL path:

```
/api/v1/users
/api/v2/users

# Not in headers (harder to test/debug)
# Not in query params (breaks caching)
```

### Version Strategy

```typescript
// v1/routes.ts
export async function v1Routes(app: FastifyInstance) {
  app.get('/users', getUsersV1)
  app.post('/users', createUserV1)
}

// v2/routes.ts
export async function v2Routes(app: FastifyInstance) {
  app.get('/users', getUsersV2)  // Breaking change in response structure
  app.post('/users', createUserV2)
}

// server.ts
app.register(v1Routes, { prefix: '/api/v1' })
app.register(v2Routes, { prefix: '/api/v2' })
```

## RFC 7807 Problem Details

Standardized error response format:

```typescript
interface ProblemDetail {
  type: string          // Error type identifier
  status: number        // HTTP status code
  title: string         // Short, human-readable summary
  detail: string        // Specific explanation for this occurrence
  instance: string      // URI reference to specific occurrence
  traceId: string       // Request trace ID for debugging
}

// Example error response
{
  "type": "NOT_FOUND",
  "status": 404,
  "title": "Not Found",
  "detail": "User with ID usr_01h455vb4pex5vsknk084sn02q not found",
  "instance": "/api/v1/users/usr_01h455vb4pex5vsknk084sn02q",
  "traceId": "req_abc123xyz"
}
```

### Error Types

```typescript
// Domain error base class
abstract class AppError extends Error {
  abstract readonly status: number
  abstract readonly type: string

  constructor(message: string, public readonly context?: ErrorContext) {
    super(message)
  }

  toResponse(instance: string, traceId: string): ProblemDetail {
    return {
      type: this.type,
      status: this.status,
      title: this.name,
      detail: this.message,
      instance,
      traceId,
      ...this.context,
    }
  }
}

// Specific error types
class NotFoundError extends AppError {
  readonly status = 404
  readonly type = 'NOT_FOUND'
}

class ConflictError extends AppError {
  readonly status = 409
  readonly type = 'CONFLICT'

  constructor(
    message: string,
    public readonly retryable: boolean = false,
    context?: ErrorContext
  ) {
    super(message, context)
  }
}

class ServiceUnavailableError extends AppError {
  readonly status = 503
  readonly type = 'SERVICE_UNAVAILABLE'

  constructor(
    message: string,
    public readonly retryable: boolean = true,
    context?: ErrorContext
  ) {
    super(message, context)
  }
}
```

See [references/error-responses.md](./references/error-responses.md) for complete examples.

## Pagination (Cursor-Based)

Use cursor-based pagination for large datasets:

```typescript
// Request
GET /api/v1/posts?limit=20&cursor=pst_01h455vb4pex5vsknk084sn02q

// Response
{
  "items": [
    { "id": "pst_01h455w3x8k5z9y7q1m0n2b3c4", ... },
    { "id": "pst_01h455x2y9l6a0z8r2n1o3c5d6", ... }
  ],
  "nextCursor": "pst_01h455z1a0m7b8y9s3o2p4d6e7",
  "hasMore": true
}
```

### Implementation

```typescript
interface PaginatedRequest {
  limit?: number   // Max items to return (default 20, max 100)
  cursor?: string  // Cursor for next page (opaque to client)
}

interface PaginatedResponse<T> {
  items: T[]
  nextCursor?: string
  hasMore: boolean
}

async function listPosts(req: PaginatedRequest): Promise<PaginatedResponse<Post>> {
  const limit = Math.min(req.limit ?? 20, 100)
  const queryLimit = limit + 1  // Fetch one extra to check hasMore

  const posts = await db.query.posts.findMany({
    where: req.cursor ? gt(posts.id, req.cursor) : undefined,
    orderBy: desc(posts.createdAt),
    limit: queryLimit,
  })

  const hasMore = posts.length > limit
  const items = posts.slice(0, limit)
  const nextCursor = hasMore ? items[items.length - 1].id : undefined

  return { items, nextCursor, hasMore }
}
```

### Why Cursor Over Offset

```
❌ Offset-based (/posts?offset=40&limit=20)
  - Unstable: Items can shift if new records inserted
  - Performance: DB must scan all previous rows
  - Inaccurate: Can miss or duplicate items

✅ Cursor-based (/posts?cursor=pst_xyz&limit=20)
  - Stable: Cursor points to specific item
  - Performant: DB uses index seek
  - Accurate: No gaps or duplicates
```

## Filtering & Sorting

Use query parameters for filtering and sorting:

```
# Filtering
GET /api/v1/users?status=active&role=admin
GET /api/v1/posts?author=usr_abc&published=true

# Sorting
GET /api/v1/posts?sort=-createdAt      # Descending (- prefix)
GET /api/v1/users?sort=name             # Ascending

# Combined
GET /api/v1/posts?author=usr_abc&status=published&sort=-createdAt&limit=20
```

### Implementation

```typescript
interface ListPostsQuery {
  author?: string
  status?: 'draft' | 'published'
  sort?: 'createdAt' | '-createdAt' | 'title' | '-title'
  limit?: number
  cursor?: string
}

async function listPosts(query: ListPostsQuery): Promise<PaginatedResponse<Post>> {
  const conditions = []

  if (query.author) {
    conditions.push(eq(posts.authorId, query.author))
  }
  if (query.status) {
    conditions.push(eq(posts.status, query.status))
  }

  const orderByColumn = query.sort?.startsWith('-')
    ? query.sort.slice(1)
    : query.sort ?? 'createdAt'
  const orderByDirection = query.sort?.startsWith('-') ? desc : asc

  return await db.query.posts.findMany({
    where: conditions.length > 0 ? and(...conditions) : undefined,
    orderBy: orderByDirection(posts[orderByColumn]),
    limit: query.limit ?? 20,
  })
}
```

## HTTP Status Codes

Use status codes consistently:

```
# Success
200 OK               # Successful GET, PUT, PATCH
201 Created          # Successful POST (include Location header)
204 No Content       # Successful DELETE, PUT with no response body

# Client Errors
400 Bad Request      # Invalid request body/parameters
401 Unauthorized     # Missing or invalid authentication
403 Forbidden        # Valid auth, but lacks permission
404 Not Found        # Resource doesn't exist
409 Conflict         # Resource already exists, optimistic lock failure
422 Unprocessable    # Validation error (semantic)
429 Too Many Requests # Rate limit exceeded

# Server Errors
500 Internal Server Error  # Unexpected error
503 Service Unavailable    # Temporary unavailability, retry later
```

## Response Envelope (When to Use)

**Don't use envelopes for simple CRUD:**

```typescript
// ❌ Unnecessary wrapping
GET /api/v1/users/123
{
  "success": true,
  "data": { "id": "123", "name": "Alice" }
}

// ✅ Return resource directly
GET /api/v1/users/123
{
  "id": "123",
  "name": "Alice"
}
```

**Use envelopes for pagination:**

```typescript
// ✅ Envelope needed for metadata
GET /api/v1/users?limit=20
{
  "items": [...],
  "nextCursor": "usr_xyz",
  "hasMore": true
}
```

## Timestamps

Use ISO 8601 format for all timestamps:

```typescript
{
  "createdAt": "2024-01-15T14:30:00.000Z",  // ISO 8601 UTC
  "updatedAt": "2024-01-16T09:15:30.123Z"
}

// In entities
toResponse(): UserResponse {
  return {
    ...
    createdAt: this.createdAt.toISOString(),  // Date → ISO string
    updatedAt: this.updatedAt.toISOString(),
  }
}
```

## Idempotency

Use idempotency keys for safe retries:

```typescript
// Request
POST /api/v1/transactions
Headers:
  Idempotency-Key: txn_abc123xyz
Body:
  { "amount": 100, "from": "usr_123", "to": "usr_456" }

// Implementation
async function createTransaction(rq: CreateTransactionRequest, idempotencyKey: string) {
  // Check if transaction with this key already exists
  const existing = await db.query.transactions.findFirst({
    where: eq(transactions.idempotencyKey, idempotencyKey),
  })

  if (existing) {
    return TransactionEntity.fromRecord(existing)  // Return existing
  }

  // Create new transaction
  const transaction = TransactionEntity.fromRequest(rq, idempotencyKey)
  return await transactionRepo.create(transaction)
}
```

## Guidelines

1. **Plural nouns** - Collections use plural resource names
2. **Lowercase with hyphens** - Multi-word resources like `ledger-accounts`
3. **Version in URL** - `/api/v1/`, `/api/v2/` for breaking changes
4. **RFC 7807 errors** - Standardized error response format
5. **Cursor pagination** - For large datasets (more stable than offset)
6. **Query params** - For filtering, sorting, pagination (not in path)
7. **HTTP status codes** - Use correct codes (200, 201, 204, 400, 404, 409, 500, 503)
8. **ISO 8601 timestamps** - Always use `.toISOString()` for dates
9. **Idempotency keys** - For non-idempotent operations (POST, PATCH)
10. **No unnecessary envelopes** - Return resources directly unless pagination needed

Overview

This skill codifies REST API design patterns and practical rules for building consistent, maintainable APIs. It focuses on resource naming, versioning, error responses, pagination, filtering, status codes, timestamps, and idempotency. Use it to make APIs predictable, debuggable, and easy to evolve.

How this skill works

The skill inspects API surface decisions and recommends framework-agnostic patterns: plural resource names, URL versioning, cursor-based pagination, RFC 7807 problem details for errors, and sensible HTTP status usage. It provides concrete examples and small implementation snippets for pagination, error classes, idempotency keys, and query parameter handling. The guidance can be applied during API design reviews, spec writing, or implementation planning.

When to use it

  • Designing new REST endpoints or reorganizing existing routes
  • Defining error response formats and debugging conventions
  • Choosing pagination and filtering strategies for large datasets
  • Deciding API versioning and migration approaches
  • Specifying HTTP status codes and response shapes for client-server contracts

Best practices

  • Use plural, lowercase resource names; avoid deep nesting (max two levels)
  • Version APIs in the URL path (/api/v1) for clearer testing and caching
  • Adopt RFC 7807 Problem Details for structured error responses including traceId and instance
  • Prefer cursor-based pagination for large or changing datasets; use envelopes only for paginated responses
  • Use ISO 8601 UTC timestamps and toISOString() for serialization
  • Require idempotency keys for non-idempotent POSTs to prevent duplicate processing

Example use cases

  • Design a public user and posts API with consistent resource paths and nested endpoints
  • Define a standard error middleware that converts domain errors to RFC 7807 responses with trace IDs
  • Implement cursor-based pagination for feeds or audit logs to ensure stable pagination and performant queries
  • Add idempotency handling to a payments or transactions endpoint to make retries safe
  • Create a versioning plan to introduce breaking response changes while supporting v1 and v2 concurrently

FAQ

When should I use envelopes around responses?

Avoid envelopes for simple single-resource CRUD responses; return the resource directly. Use envelopes when you need metadata such as items, nextCursor, and hasMore for paginated lists.

Why prefer cursor over offset pagination?

Cursor pagination is stable and performant: it avoids gaps or duplicates when data changes and lets the database use index seeks instead of scanning previous rows.