Builds production-ready HTTP APIs with thin handlers, consistent error envelopes, health/readiness checks, CORS, idempotency, rate limiting, and graceful shutdown. Use when designing or implementing HTTP endpoints, writing orpc route factories, defining Zod request/response schemas, mapping domain errors to status codes, or adding operational concerns (health checks, X-Request-ID, Retry-After) to a TypeScript API.
How this skill is triggered — by the user, by Claude, or both
Slash command
/jagreehal-claude-skills:api-designThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
HTTP handlers have one job: **translate between HTTP and your domain**. They validate input at the boundary, call business logic written as `fn(args, deps)` returning a `Result`, and map that result to an HTTP response. Handlers stay thin and contain no business logic. That keeps them testable, keeps domain code transport-agnostic, and keeps error/operational concerns consistent across every en...
HTTP handlers have one job: translate between HTTP and your domain. They validate input at the boundary, call business logic written as fn(args, deps) returning a Result, and map that result to an HTTP response. Handlers stay thin and contain no business logic. That keeps them testable, keeps domain code transport-agnostic, and keeps error/operational concerns consistent across every endpoint.
HTTP Request
|
v
Handler (thin layer)
|-> Validate Input (Zod via framework)
|-> Call Business Logic: fn(args, deps) -> Result
|-> Map Result to HTTP Response
|
v
HTTP Response
When NOT to use: Internal function-to-function calls (no HTTP boundary), pure domain logic, or background jobs that never face an HTTP request. Validate-at-boundary logic belongs in validation-boundary; error modelling belongs in result-types.
Related: fn-args-deps (the handler delegates to these), validation-boundary (input validation at the edge), result-types (what business logic returns), resilience (retry/timeout around calls), observability (request IDs and tracing).
For how this layer fits the whole system, see references/architecture.md.
Each route follows fn(args, deps) and uses a factory for dependency injection:
// routes/posts/get-post.ts
import { os, ORPCError } from "@orpc/server";
import { z } from "zod";
import type { PostRepository } from "./types";
// Explicit deps type for this route
type GetPostDeps = {
postRepo: PostRepository;
};
// Factory function: creates route with injected deps
export function createGetPost({ deps }: { deps: GetPostDeps }) {
return os
.input(z.object({ postId: z.string().uuid() }))
.output(PostResponse)
.handler(async ({ input }) => {
const post = await deps.postRepo.findById({ id: input.postId });
if (!post) {
throw new ORPCError("NOT_FOUND", {
status: 404,
message: `Post ${input.postId} not found`,
});
}
return post;
});
}
All errors MUST use the same JSON shape:
const ErrorResponse = z.object({
code: z.string(), // Machine-readable: "NOT_FOUND"
message: z.string(), // Human-readable explanation
requestId: z.string(), // For correlation in logs
details: z.unknown().optional(),
});
function createErrorResponse(
code: string,
message: string,
requestId: string,
details?: unknown
) {
return { code, message, requestId, details };
}
Rule: ORPCError(code) must match ErrorResponse.code:
// CORRECT - codes match
throw new ORPCError("NOT_FOUND", {
status: 404,
data: createErrorResponse("NOT_FOUND", "User not found", requestId),
});
// WRONG - codes mismatch
throw new ORPCError("BAD_REQUEST", {
data: createErrorResponse("MISSING_FIELD", ...), // Confusing!
});
Map all error types consistently:
const errorToStatus: Record<string, number> = {
NOT_FOUND: 404,
UNAUTHORIZED: 401,
FORBIDDEN: 403,
VALIDATION_FAILED: 400,
CONFLICT: 409,
TOO_MANY_REQUESTS: 429,
SERVICE_UNAVAILABLE: 503,
};
// /health - Liveness (is process running?)
export const health = os.handler(() => ({ status: "ok" }));
// /ready - Readiness (can handle traffic?)
export const ready = os.handler(async () => {
const checks = {
database: await checkDatabase(),
cache: await checkCache(),
};
const allHealthy = Object.values(checks).every(Boolean);
if (!allHealthy) {
throw new ORPCError("SERVICE_UNAVAILABLE", {
status: 503,
data: { status: "not_ready", checks },
});
}
return { status: "ready", checks };
});
Response contract:
{ status: "ready", checks }{ status: "not_ready", checks }Set this header centrally, not in each handler:
// In top-level request middleware or response hook
response.headers.set("X-Request-ID", context.requestId);
new CORSPlugin({
origin: (origin) => {
// No Origin = not a browser request (curl, server-to-server)
// Return null = "don't emit CORS headers" (request proceeds normally)
if (!origin) return null;
// Return origin string to allow, null to deny
return ALLOWED_ORIGINS.has(origin) ? origin : null;
},
allowMethods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
allowHeaders: ['Content-Type', 'Authorization', 'Idempotency-Key'],
credentials: true,
});
export const createOrder = os
.input(CreateOrderInput)
.handler(async ({ input, context }) => {
const { req, requestId } = context as AppContext;
const idempotencyKey = req.headers.get('idempotency-key');
if (!idempotencyKey) {
throw new ORPCError("MISSING_IDEMPOTENCY_KEY", {
status: 400,
data: createErrorResponse(
"MISSING_IDEMPOTENCY_KEY",
"Idempotency-Key header required",
requestId
),
});
}
// Check cache for existing result, or process and store
// ...
});
throw new ORPCError("TOO_MANY_REQUESTS", {
status: 429,
headers: { "Retry-After": "60" }, // Seconds until retry allowed
data: createErrorResponse(
"TOO_MANY_REQUESTS",
"Rate limit exceeded. Try again in 60 seconds.",
requestId
),
});
let isShuttingDown = false;
process.on('SIGTERM', () => {
isShuttingDown = true;
// Wait for in-flight requests, then exit
});
// In handlers or middleware
if (isShuttingDown) {
throw new ORPCError("SERVICE_UNAVAILABLE", {
status: 503,
message: "Server is shutting down",
});
}
One file per route with co-located tests:
routes/
├── posts/
│ ├── get-post.ts # Route factory
│ ├── get-post.test.ts # Co-located test
│ ├── list-posts.ts
│ ├── create-post.ts
│ ├── index.ts # Composes postsRouter
│ └── schemas.ts # Shared Zod schemas
└── index.ts # Composes apiRouter
Naming conventions:
| Operation | File Name | Factory | Router Key |
|---|---|---|---|
| Get one | get-post.ts | createGetPost | getPost |
| List | list-posts.ts | createListPosts | listPosts |
| Create | create-post.ts | createCreatePost | createPost |
Composition at boundaries:
// routes/posts/index.ts
export function createPostsRouter({ deps }: { deps: PostsRouterDeps }) {
return {
getPost: createGetPost({ deps }),
listPosts: createListPosts({ deps }),
createPost: createCreatePost({ deps }),
};
}
// routes/index.ts
export function createApiRouter({ deps }: { deps: ApiRouterDeps }) {
return {
posts: createPostsRouter({ deps }),
users: createUsersRouter({ deps }),
};
}
import { describe, it, expect, beforeEach } from "vitest";
import { call, ORPCError } from "@orpc/server";
import { mock } from "vitest-mock-extended";
import { createGetPost, type GetPostDeps } from "./get-post";
describe("getPost", () => {
const postId = "550e8400-e29b-41d4-a716-446655440000";
let deps: GetPostDeps;
let getPost: ReturnType<typeof createGetPost>;
beforeEach(() => {
deps = { postRepo: mock() };
getPost = createGetPost({ deps });
});
it("returns post when found", async () => {
deps.postRepo.findById.mockResolvedValue({
id: postId,
title: "Test Post",
});
const result = await call(getPost, { postId });
expect(result.title).toBe("Test Post");
});
it("throws NOT_FOUND when post missing", async () => {
deps.postRepo.findById.mockResolvedValue(null);
try {
await call(getPost, { postId });
expect.fail("Should have thrown");
} catch (error) {
expect(error).toBeInstanceOf(ORPCError);
expect((error as ORPCError).code).toBe("NOT_FOUND");
}
});
});
| Rationalization | Reality |
|---|---|
| "Just put the business logic in the handler, it's faster" | Handlers coupled to HTTP can't be reused or tested without spinning up a server. Keep them thin; delegate to fn(args, deps). |
| "Each endpoint can shape its own errors" | Inconsistent error bodies force every consumer to special-case each endpoint. Use one envelope everywhere. |
| "We'll add health checks before launch" | Orchestrators (k8s, ECS) need /health and /ready to route traffic safely. Add them from the start. |
| "Idempotency keys are overkill" | Network retries WILL replay your POST. Without a key, you double-charge or double-create. |
| "We can retry inside the handler" | Retry belongs at the workflow level, once. See resilience for why double-retry is dangerous. |
| "Set X-Request-ID in each handler" | Per-handler header logic drifts. Set it once in middleware so every response is correlated. |
ORPCError(code) not matching ErrorResponse.codeIdempotency-Key handling/health or /ready endpointsRetry-After missing on 429 responsesAfter implementing an API surface:
deps and delegates to business logicORPCError code/health (200) and /ready (200/503) endpoints existIdempotency-KeyRetry-After; shutdown drains with 503X-Request-ID is set centrally in middleware| Concern | Pattern | Where |
|---|---|---|
| Input validation | Zod schemas | Framework boundary |
| Error format | Consistent envelope | All error responses |
| Error codes | ORPCError(code) = ErrorResponse.code | Handler |
| Request ID | Set in middleware | Top-level |
| Health check | /health (200) | Liveness |
| Readiness | /ready (200/503) | Traffic routing |
| CORS | Plugin with origin callback | Framework config |
| Idempotency | Idempotency-Key header | Create/mutate endpoints |
| Rate limits | 429 + Retry-After header | Middleware |
| Shutdown | 503 during drain | All endpoints |
| Route files | One per route, co-located tests | Organization |
npx claudepluginhub jagreehal/jagreehal-claude-skills --plugin skill-authoringProvides behavioral guidelines to reduce common LLM coding mistakes, focusing on simplicity, surgical changes, assumption surfacing, and verifiable success criteria.
Searches, retrieves, and installs Agent Skills from prompts.chat registry using MCP tools like search_skills and get_skill. Activates for finding skills, browsing catalogs, or extending Claude.
Creates, edits, and optimizes skills for Claude Code, including drafting, evaluating with test prompts, iterating on performance, and improving skill descriptions for better triggering accuracy.