Skip to main content

API-First vs Code-First Development 2026

·APIScout Team
api designapi-firstcode-firstopenapidesign philosophyschema-firstdeveloper experienceapi architecture

The Question Every API Team Faces

Before writing a single line of implementation code for a new API endpoint, you face a choice: define the API contract first (OpenAPI spec, Protobuf schema), then implement it — or write the implementation code and derive the API contract from it.

Both approaches work. Both have production deployments at scale. The right choice depends on your team structure, architecture, and what problem you're optimizing for.

In 2026, the landscape has shifted: code-first has become more capable (tRPC's TypeScript inference, FastAPI's Pydantic schema generation), while API-first tooling has become more developer-friendly (Stoplight's visual editor, Buf's Protobuf toolchain). The choice is no longer "developer experience vs contract rigor" — it's about which workflow fits your team.

TL;DR

API-first is the right choice when: multiple teams consume the API, the API is public or has external consumers, you need to design the API contract collaboratively (product, engineering, API consumers all review the spec), or you're using a language-agnostic schema (OpenAPI, Protobuf) that generates clients for multiple languages. Code-first is the right choice when: a single team builds and consumes the API, you're using a TypeScript full-stack (tRPC's type inference is the best developer experience available), or you're moving quickly and want to avoid the overhead of maintaining a separate schema file.

Key Takeaways

  • API-first defines the contract before implementation — the OpenAPI spec (or Protobuf) is the source of truth, and implementation is validated against it.
  • Code-first derives the contract from implementation — FastAPI generates OpenAPI from Python type hints; tRPC infers TypeScript types directly; Zod schemas generate both validation and documentation.
  • tRPC is not API-first or code-first in the traditional sense — it eliminates the API contract layer entirely for TypeScript full-stack apps. The types are the contract.
  • OpenAPI is the standard for REST API documentation — API-first with OpenAPI produces the spec that powers Swagger UI, Stoplight, Postman collections, and generated SDKs.
  • Protobuf is the schema for RPC APIs — API-first with Protobuf generates type-safe clients in every language via protoc or Buf.
  • Mock servers from specs are a key API-first benefit: Stoplight Prism and Microcks generate working mock servers from OpenAPI specs, letting frontend teams develop against a mock before the backend is implemented.
  • Design-time validation is the strongest API-first argument: tools like 42Crunch, Spectral, and Stoplight catch breaking changes, security issues, and style violations in the spec before any implementation.

What Is API-First?

API-first development treats the API contract as the primary artifact of the design process — written, reviewed, and agreed upon before implementation begins.

The API-First Workflow

1. Define API contract (OpenAPI YAML / Protobuf)
2. Review contract with stakeholders (frontend, backend, consumers)
3. Generate mock server from spec — frontend develops against mock
4. Implement backend to spec
5. Validate implementation against spec (Prism, Schemathesis)
6. Generate client SDKs from spec
7. Deploy

OpenAPI Spec (API-First REST)

# openapi.yaml — written BEFORE any backend code
openapi: 3.1.0
info:
  title: Users API
  version: 1.0.0
  description: API for managing user accounts

paths:
  /users/{id}:
    get:
      operationId: getUser
      summary: Get user by ID
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      security:
        - BearerAuth: []
      responses:
        "200":
          description: User found
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/User"
              example:
                id: "550e8400-e29b-41d4-a716-446655440000"
                email: "user@example.com"
                name: "Jane Smith"
                plan: "professional"
        "404":
          description: User not found
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /users:
    post:
      operationId: createUser
      summary: Create a new user
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CreateUserRequest"
      responses:
        "201":
          description: User created
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/User"
        "400":
          description: Validation error

components:
  schemas:
    User:
      type: object
      required: [id, email, name, plan, createdAt]
      properties:
        id:
          type: string
          format: uuid
        email:
          type: string
          format: email
        name:
          type: string
        plan:
          type: string
          enum: [free, professional, enterprise]
        createdAt:
          type: string
          format: date-time

    CreateUserRequest:
      type: object
      required: [email, name]
      properties:
        email:
          type: string
          format: email
        name:
          type: string
          minLength: 1
        plan:
          type: string
          enum: [free, professional, enterprise]
          default: free

    Error:
      type: object
      required: [message, code]
      properties:
        message:
          type: string
        code:
          type: string

  securitySchemes:
    BearerAuth:
      type: http
      scheme: bearer

Mock Server from Spec

# Stoplight Prism — generate mock server from OpenAPI spec
npx @stoplight/prism-cli mock openapi.yaml

# Mock server runs at http://localhost:4010
# Frontend team develops against mock before backend is ready
curl http://localhost:4010/users/550e8400-e29b-41d4-a716-446655440000
# Returns the example from the OpenAPI spec

SDK Generation from Spec

# Generate TypeScript SDK from OpenAPI spec
npx @hey-api/openapi-ts \
  --input openapi.yaml \
  --output src/client \
  --client axios

# Generated SDK:
# src/client/services/UsersService.ts
# Fully typed based on OpenAPI schema
// Generated TypeScript client — fully typed from OpenAPI
import { UsersService } from "./client/services/UsersService";

// TypeScript knows:
// - getUser returns User | null
// - User has id, email, name, plan, createdAt
// - plan is 'free' | 'professional' | 'enterprise'
const user = await UsersService.getUser({ id: "550e8400-..." });
console.log(user.plan); // TypeScript-safe

Breaking Change Detection

# oasdiff — detect breaking changes between spec versions
npx oasdiff breaking openapi-v1.yaml openapi-v2.yaml

# Output:
# [ERROR] GET /users/{id} response 200 schema changed: 'name' field removed
# [WARNING] POST /users request body: new required field 'company' added
# → Breaking change: existing clients will fail

What Is Code-First?

Code-first development writes the implementation code first and derives the API contract from it.

Code-First with FastAPI (Python)

# FastAPI generates OpenAPI automatically from Python type hints
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel, EmailStr
from typing import Optional
from enum import Enum
import uuid

app = FastAPI(
    title="Users API",
    version="1.0.0",
    description="API for managing user accounts",
)

class Plan(str, Enum):
    free = "free"
    professional = "professional"
    enterprise = "enterprise"

class User(BaseModel):
    id: str
    email: EmailStr
    name: str
    plan: Plan
    created_at: str

class CreateUserRequest(BaseModel):
    email: EmailStr
    name: str
    plan: Optional[Plan] = Plan.free

@app.get("/users/{user_id}", response_model=User, tags=["users"])
async def get_user(user_id: str):
    """Get a user by their ID."""
    user = await db.users.find_by_id(user_id)
    if not user:
        raise HTTPException(status_code=404, detail="User not found")
    return user

@app.post("/users", response_model=User, status_code=201, tags=["users"])
async def create_user(data: CreateUserRequest):
    """Create a new user account."""
    user = await db.users.create(
        id=str(uuid.uuid4()),
        email=data.email,
        name=data.name,
        plan=data.plan,
    )
    return user

# OpenAPI spec auto-generated at /openapi.json
# Swagger UI at /docs
# ReDoc at /redoc

Code-First with tRPC (TypeScript)

// tRPC — no OpenAPI, no schema file, just TypeScript
// The TypeScript types ARE the contract
import { router, publicProcedure } from "./trpc";
import { z } from "zod";

export const usersRouter = router({
  getUser: publicProcedure
    .input(z.object({ id: z.string().uuid() }))
    .query(async ({ input }) => {
      const user = await db.users.findUnique({ where: { id: input.id } });
      if (!user) throw new TRPCError({ code: "NOT_FOUND" });
      return user;  // Return type inferred from Prisma schema
    }),

  createUser: publicProcedure
    .input(z.object({
      email: z.string().email(),
      name: z.string().min(1),
      plan: z.enum(["free", "professional", "enterprise"]).default("free"),
    }))
    .mutation(async ({ input }) => {
      return db.users.create({ data: input });
    }),
});

// No OpenAPI spec generated
// TypeScript client has full type inference from above:
const user = await trpc.users.getUser.query({ id: "..." });
user.plan;  // TypeScript knows: "free" | "professional" | "enterprise"

Code-First with Zod + Express

// Zod schemas as the source of truth
import { z } from "zod";
import { generateOpenApi } from "@ts-rest/open-api";

const UserSchema = z.object({
  id: z.string().uuid(),
  email: z.string().email(),
  name: z.string(),
  plan: z.enum(["free", "professional", "enterprise"]),
  createdAt: z.string().datetime(),
});

const CreateUserSchema = z.object({
  email: z.string().email(),
  name: z.string().min(1),
  plan: z.enum(["free", "professional", "enterprise"]).default("free"),
});

// Contract defined from Zod schemas — ts-rest generates both Express routes and OpenAPI
const contract = initContract();
export const usersContract = contract.router({
  getUser: {
    method: "GET",
    path: "/users/:id",
    responses: {
      200: UserSchema,
      404: z.object({ message: z.string() }),
    },
    pathParams: z.object({ id: z.string() }),
  },
  createUser: {
    method: "POST",
    path: "/users",
    body: CreateUserSchema,
    responses: {
      201: UserSchema,
    },
  },
});

// Generate OpenAPI from the contract
const openApiDocument = generateOpenApi(usersContract, {
  info: { title: "Users API", version: "1.0.0" },
});

When to Choose API-First

✓ Multiple teams or companies consume your API
✓ External (public) API where the contract is a product
✓ Need to design collaboratively before implementation
✓ Frontend team needs to develop before backend is ready (mock server)
✓ Generating clients in multiple languages (TypeScript, Python, Go SDKs)
✓ Need formal breaking change detection between versions
✓ Regulated industries where API contracts require sign-off
✓ Microservices where teams need stable interface contracts

When to Choose Code-First

✓ Single team builds and consumes the API (monorepo, full-stack TypeScript)
✓ Moving quickly and want to avoid schema maintenance overhead
✓ TypeScript full-stack — tRPC's type inference is superior DX
✓ FastAPI/Django REST Framework already generates good OpenAPI output
✓ Prototyping where the API contract will change frequently
✓ Internal tools consumed by a small, known set of clients

The Hybrid Approach

Many mature API teams use both:

1. Code-first for internal/internal services (tRPC, FastAPI auto-generation)
2. API-first for external/public API surfaces (written OpenAPI spec with review process)
3. Treat auto-generated specs as a starting point, not the source of truth

Example:
- Internal: Product team ← uses tRPC (code-first, zero contract overhead)
- External: Partner API → writes OpenAPI spec first, implements against it
- Customer-facing SDK: Generated from the external OpenAPI spec

Protobuf: Schema-First for RPC

For gRPC and Connect-RPC, the .proto file is always the source of truth — schema-first by design:

// users.proto — this is the API contract
// protoc or Buf generates clients in any language
syntax = "proto3";
package users.v1;

service UserService {
  rpc GetUser (GetUserRequest) returns (GetUserResponse);
  rpc CreateUser (CreateUserRequest) returns (CreateUserResponse);
}

message GetUserRequest {
  string id = 1;
}

message GetUserResponse {
  string id = 1;
  string email = 2;
  string name = 3;
  UserPlan plan = 4;
}

enum UserPlan {
  FREE = 0;
  PROFESSIONAL = 1;
  ENTERPRISE = 2;
}
# Buf — generate from Protobuf schema
buf generate
# Generates: TypeScript, Go, Python, Java clients from the same .proto

Decision Framework

FactorAPI-FirstCode-First
Multiple API consumersBestRisky
External/public APIRequiredPossible
TypeScript monorepoOverkillBest (tRPC)
Python backendEither (FastAPI auto-gen)Good
Cross-language clientsRequiredManual
Mock servers neededYesManual setup
Breaking change detectionNativeManual
Design collaborationNativeRequires extract
Speed of iterationSlowerFaster
Schema drift riskLowHigh

Verdict

API-first is the right architectural choice for APIs that are products — public APIs, partner APIs, multi-team internal APIs where the contract is the interface between teams. The design review process, mock servers, and generated clients from a formal spec create a discipline that prevents interface drift.

Code-first is the right choice for internal development velocity — particularly TypeScript full-stack teams using tRPC, where type inference from server to client eliminates an entire class of API bugs without any schema maintenance overhead.

The real answer: use both strategically. Code-first for internal service-to-service communication within a team; API-first for contracts that cross team or organizational boundaries.


Compare API design tools, OpenAPI generators, and documentation platforms at APIScout — find the right API design workflow for your team.

Comments