teachyou.ai academy
← All posts
Claude Code

Claude Code for API Development: Design to Implementation

Ira Menon · Jun 26, 2026 · 13 min read

Why API Development Is a Great Fit for Claude Code

Every backend engineer knows the drill. You get a ticket that says "add an endpoint to let users update their notification preferences," and suddenly you're juggling six different concerns at once: the route definition, the request validation schema, the database query, the error handling, the response shape, and the tests that prove all of it works together. None of these steps is individually hard. What makes API work tedious is the sheer number of small, correctness-sensitive decisions you have to make in sequence, and how easy it is for one sloppy decision early on to cause a bug three layers downstream.

This is exactly the kind of work where Claude Code earns its keep. It is not magic that replaces your judgment about system design — it is a coding agent that reads your existing codebase, understands your conventions, and then writes routes, schemas, and tests in a way that is consistent with what is already there. The reason this matters for APIs specifically is that APIs are contracts. A contract that is inconsistent — one endpoint returning snake_case, another camelCase, one throwing raw exceptions, another wrapping errors in an envelope — creates friction for every client that consumes it. Claude Code, when given the right instructions, is very good at holding a contract steady across dozens of endpoints because it can actually read all of them before writing the next one.

In this article we're going to walk through a realistic, end-to-end workflow for using Claude Code to go from an API design (even a rough one) to a working, tested implementation. We'll cover how to brief the tool, how to structure the design step, how to have it scaffold routes and validation, how to get it to write meaningful tests, and how to review its output like you would a junior engineer's pull request. We'll use a Node/Express and TypeScript example throughout because it's widely understood, but the workflow itself transfers directly to FastAPI, Django REST Framework, Spring Boot, or Go's net/http — the steps don't change, only the syntax does.

Step One: Treat the API Spec as the Prompt, Not an Afterthought

The single biggest mistake developers make when using an AI coding tool for API work is skipping straight to "write me a REST API for users." That prompt is too vague to produce anything you'd actually want to ship. Claude Code performs dramatically better when you give it a real specification to work against — even a lightweight one.

Before opening Claude Code, spend ten minutes writing down:

  • The resource and its fields, with types and which ones are required versus optional
  • The HTTP methods and paths (GET /orders/:id, POST /orders, PATCH /orders/:id/status)
  • Authentication and authorization rules for each route
  • Expected status codes for success and failure cases
  • Any pagination, filtering, or sorting behavior for list endpoints

You don't need Swagger-level formality. A markdown file or even a well-organized comment block is enough. Here's an example of the kind of spec that gets good results:

## Orders API

### POST /api/orders
- Auth: required (customer role)
- Body: { items: [{ productId: string, quantity: number }], shippingAddressId: string }
- 201: returns the created order with computed totals
- 400: invalid items array, quantity <= 0, or unknown productId
- 404: shippingAddressId not found for this user

### GET /api/orders/:id
- Auth: required (must own the order, or be admin)
- 200: full order object with line items and status history
- 403: order exists but belongs to another user
- 404: order does not exist

### PATCH /api/orders/:id/status
- Auth: required (admin role only)
- Body: { status: "pending" | "shipped" | "delivered" | "cancelled" }
- 200: updated order
- 409: invalid status transition (e.g. delivered -> pending)

Feed this directly into Claude Code as the first message of a session. The model will ask clarifying questions if something is ambiguous — let it. A five-minute back-and-forth about what happens when a cancelled order gets a status update is far cheaper than discovering the gap in production.

Step Two: Let Claude Code Read Before It Writes

Once you have a spec, resist the urge to immediately say "implement this." A better first instruction is something like:

Before writing any code, read through the existing src/routes,
src/models, and src/middleware directories. Tell me which patterns
I'm already using for validation, error handling, and auth checks,
so the new Orders endpoints match the existing style.

This single step is what separates a generic scaffold from code that actually belongs in your repository. Claude Code will typically come back with a short summary: "You're using Zod for validation, a shared asyncHandler wrapper for routes, a custom ApiError class for thrown errors, and a requireRole middleware for authorization." That summary is worth reading carefully — it's your chance to correct any misunderstanding before fifty lines of code get generated on a wrong assumption.

If you're working in a repo with a CLAUDE.md file (or the equivalent project memory file), this is where it pays off. Document your API conventions once — error envelope shape, naming case, versioning scheme — and every future session starts from the same baseline instead of re-deriving it.

Step Three: Scaffold the Route Layer

With conventions confirmed, ask Claude Code to scaffold the route handlers first, before touching the database layer. This keeps the change small and reviewable. A typical result for the orders spec above looks like this:

// src/routes/orders.ts
import { Router } from "express";
import { z } from "zod";
import { asyncHandler } from "../middleware/asyncHandler";
import { requireAuth, requireRole } from "../middleware/auth";
import { ApiError } from "../errors/ApiError";
import * as ordersService from "../services/ordersService";

const router = Router();

const createOrderSchema = z.object({
  items: z
    .array(
      z.object({
        productId: z.string().uuid(),
        quantity: z.number().int().positive(),
      })
    )
    .min(1, "Order must contain at least one item"),
  shippingAddressId: z.string().uuid(),
});

router.post(
  "/",
  requireAuth,
  asyncHandler(async (req, res) => {
    const parsed = createOrderSchema.safeParse(req.body);
    if (!parsed.success) {
      throw new ApiError(400, "Invalid order payload", parsed.error.flatten());
    }

    const order = await ordersService.createOrder({
      userId: req.user.id,
      items: parsed.data.items,
      shippingAddressId: parsed.data.shippingAddressId,
    });

    res.status(201).json(order);
  })
);

router.get(
  "/:id",
  requireAuth,
  asyncHandler(async (req, res) => {
    const order = await ordersService.getOrderById(req.params.id);
    if (!order) throw new ApiError(404, "Order not found");

    if (order.userId !== req.user.id && req.user.role !== "admin") {
      throw new ApiError(403, "You do not have access to this order");
    }

    res.status(200).json(order);
  })
);

const statusSchema = z.object({
  status: z.enum(["pending", "shipped", "delivered", "cancelled"]),
});

router.patch(
  "/:id/status",
  requireAuth,
  requireRole("admin"),
  asyncHandler(async (req, res) => {
    const parsed = statusSchema.safeParse(req.body);
    if (!parsed.success) {
      throw new ApiError(400, "Invalid status value");
    }

    const updated = await ordersService.updateOrderStatus(
      req.params.id,
      parsed.data.status
    );
    res.status(200).json(updated);
  })
);

export default router;

Notice what's absent here: no database calls, no business logic about valid status transitions. That's intentional, and it's a instruction worth giving explicitly — "keep the route handlers thin, push logic into a service module." Thin route handlers are easier for Claude Code (and for you) to review in isolation, because the only thing you're checking at this stage is: does the HTTP layer do what the spec says — right status codes, right auth checks, right validation?

Step Four: Push Business Logic Into a Service Layer

The next instruction should ask Claude Code to implement the service functions the routes reference, and specifically to handle the trickier business rules from the spec — like the "delivered can't go back to pending" transition rule. This is where you should expect to iterate. A first draft might miss an edge case; that's fine, this is a conversation, not a one-shot generation.

// src/services/ordersService.ts
import { db } from "../db/client";
import { ApiError } from "../errors/ApiError";

const VALID_TRANSITIONS: Record<string, string[]> = {
  pending: ["shipped", "cancelled"],
  shipped: ["delivered", "cancelled"],
  delivered: [],
  cancelled: [],
};

export async function createOrder(params: {
  userId: string;
  items: { productId: string; quantity: number }[];
  shippingAddressId: string;
}) {
  const address = await db.address.findFirst({
    where: { id: params.shippingAddressId, userId: params.userId },
  });
  if (!address) {
    throw new ApiError(404, "Shipping address not found for this user");
  }

  const products = await db.product.findMany({
    where: { id: { in: params.items.map((i) => i.productId) } },
  });
  if (products.length !== params.items.length) {
    throw new ApiError(400, "One or more products do not exist");
  }

  const total = params.items.reduce((sum, item) => {
    const product = products.find((p) => p.id === item.productId)!;
    return sum + product.price * item.quantity;
  }, 0);

  return db.order.create({
    data: {
      userId: params.userId,
      shippingAddressId: params.shippingAddressId,
      status: "pending",
      total,
      items: {
        create: params.items.map((item) => ({
          productId: item.productId,
          quantity: item.quantity,
        })),
      },
    },
    include: { items: true },
  });
}

export async function getOrderById(id: string) {
  return db.order.findUnique({ where: { id }, include: { items: true } });
}

export async function updateOrderStatus(id: string, nextStatus: string) {
  const order = await db.order.findUnique({ where: { id } });
  if (!order) throw new ApiError(404, "Order not found");

  const allowed = VALID_TRANSITIONS[order.status] ?? [];
  if (!allowed.includes(nextStatus)) {
    throw new ApiError(
      409,
      `Cannot transition order from ${order.status} to ${nextStatus}`
    );
  }

  return db.order.update({
    where: { id },
    data: { status: nextStatus },
  });
}

This is the layer where you, the human, need to read closely. Claude Code will faithfully implement the transition table you described, but only you know whether "cancelled" should really be a dead end, or whether refunds need to happen somewhere in this flow. Treat this file as the one requiring the most scrutiny — it's where the actual business meaning of the API lives.

Step Five: Generate Tests That Actually Exercise the Contract

A common failure mode with AI-assisted API development is ending up with tests that check implementation details instead of the contract. Ask explicitly for contract-level tests: hit the route, assert on status code and response shape, and cover the failure paths from your original spec, not just the happy path.

// src/routes/orders.test.ts
import request from "supertest";
import { app } from "../app";
import { createTestUser, createTestAddress, createTestProduct } from "../test/helpers";

describe("POST /api/orders", () => {
  it("creates an order and returns 201 with computed total", async () => {
    const user = await createTestUser();
    const address = await createTestAddress(user.id);
    const product = await createTestProduct({ price: 25 });

    const res = await request(app)
      .post("/api/orders")
      .set("Authorization", `Bearer ${user.token}`)
      .send({
        items: [{ productId: product.id, quantity: 2 }],
        shippingAddressId: address.id,
      });

    expect(res.status).toBe(201);
    expect(res.body.total).toBe(50);
    expect(res.body.items).toHaveLength(1);
  });

  it("returns 400 when quantity is zero or negative", async () => {
    const user = await createTestUser();
    const address = await createTestAddress(user.id);
    const product = await createTestProduct();

    const res = await request(app)
      .post("/api/orders")
      .set("Authorization", `Bearer ${user.token}`)
      .send({
        items: [{ productId: product.id, quantity: 0 }],
        shippingAddressId: address.id,
      });

    expect(res.status).toBe(400);
  });

  it("returns 404 when shippingAddressId belongs to another user", async () => {
    const user = await createTestUser();
    const otherUser = await createTestUser();
    const otherAddress = await createTestAddress(otherUser.id);
    const product = await createTestProduct();

    const res = await request(app)
      .post("/api/orders")
      .set("Authorization", `Bearer ${user.token}`)
      .send({
        items: [{ productId: product.id, quantity: 1 }],
        shippingAddressId: otherAddress.id,
      });

    expect(res.status).toBe(404);
  });
});

describe("PATCH /api/orders/:id/status", () => {
  it("rejects an invalid transition with 409", async () => {
    const admin = await createTestUser({ role: "admin" });
    const order = await createTestOrderWithStatus("delivered");

    const res = await request(app)
      .patch(`/api/orders/${order.id}/status`)
      .set("Authorization", `Bearer ${admin.token}`)
      .send({ status: "pending" });

    expect(res.status).toBe(409);
  });
});

Once these tests exist, they become a second layer of specification — arguably a more trustworthy one than the markdown file you started with, because they actually run. From here on, any time you ask Claude Code to add a new field or endpoint, tell it to run the existing test suite first and keep it green. This turns the test suite into a guardrail that catches regressions the agent might otherwise introduce while iterating quickly.

Step Six: Use Claude Code for API Documentation, Not Just Code

Because Claude Code has already read the route definitions, validation schemas, and service logic, it's well-positioned to generate accurate documentation from that same context — an OpenAPI/Swagger spec, or a plain markdown reference for internal consumers. The key is to ask for documentation generation as a separate pass, after the implementation is stable, and to ask it to derive the docs from the actual code rather than from your original informal spec. This catches drift: if the implementation ended up rejecting quantities above 100 (something you added mid-session and forgot to mention), documentation generated from the code will reflect that; documentation generated from your original notes won't.

A useful habit here is asking Claude Code to flag any place where the implementation diverges from the original spec you gave it at the start of the session. This two-way check — spec against code, code against spec — surfaces the kind of small inconsistencies that would otherwise turn into a confusing bug report from a frontend developer three weeks later.

Step Seven: Reviewing Claude Code's API Output Like a Senior Engineer Would

Speed is only useful if the output is correct, so treat every batch of generated code as a pull request from a capable but unfamiliar teammate. A few specific things worth checking every time for API code:

  • Status codes. Confirm 400 versus 422 versus 409 are used consistently with how the rest of your API already behaves, not just what feels "right" in isolation.
  • Authorization ordering. Auth and role checks should generally run before expensive database lookups. Check that requireAuth and requireRole sit ahead of business logic in the middleware chain, not sprinkled inside it.
  • Input validation boundaries. Verify that validation happens once, at the edge, and that the service layer doesn't silently trust unchecked input if it's ever called from somewhere other than the route.
  • Error message leakage. Make sure database or internal error details never leak into API responses. Ask Claude Code directly: "does any error path here expose stack traces or raw database errors to the client?"
  • N+1 queries. For list or nested-resource endpoints, check whether the generated code is looping and querying inside the loop instead of batching. This is a common and easy-to-miss issue in AI-generated data access code.
  • Idempotency for retried requests. For POST endpoints that might be retried by flaky clients, ask whether an idempotency key is needed, especially for anything involving payments or order creation.

None of these checks are unique to AI-generated code — they're the same things you'd check in any code review. The difference is that Claude Code can generate a full route-service-test triplet in the time it takes to review one of them carefully, so your review discipline has to keep pace, not lag behind.

Building the Habit Into Your Actual Workflow

The workflow above — spec, read-before-write, thin routes, service layer, contract tests, documentation, careful review — is not a rigid checklist to follow mechanically every time. For a small internal endpoint, you might collapse steps three and four into one prompt. For a public-facing payments API, you'll want to slow down and add a dedicated security review pass, checking things like rate limiting, replay protection, and signature verification on webhooks.

What stays constant is the underlying principle: Claude Code is at its best when it has a clear contract to build against and an existing codebase to stay consistent with. It is noticeably weaker when asked to both invent the design and implement it in a single unreviewed pass — that's when you get plausible-looking code that quietly makes design decisions nobody signed off on. Investing the ten minutes upfront to write a real spec, and the discipline afterward to review each layer separately, is what turns Claude Code from "a fast way to produce code" into "a fast way to produce code you'd actually approve in a code review."

If you're newer to this workflow and want a structured, hands-on introduction — covering not just API scaffolding but the broader set of habits, prompting patterns, and review practices that make Claude Code genuinely useful in a professional codebase — our Claude Code Tutorial for Beginners course walks through all of this with real projects from the ground up, starting from your first session through building and shipping a complete API.