Definition
Code-first design is an API development approach where you write the implementation code first, using framework-specific annotations or comments to describe API behavior, then automatically generate specifications (like OpenAPI), documentation, and client SDKs from the code. This contrasts with contract-first where you define the specification before implementing.
The core idea is that your source code is the single source of truth. Annotations like @Path("/users"), @ApiResponse, or decorator metadata describe endpoints, schemas, and validation rules directly in the code. Build-time or runtime tools scan the codebase, extract these annotations, and generate OpenAPI specs, Swagger UI documentation, and type definitions.
Code-first appeals to developers who prefer working in their programming language rather than YAML/JSON. It promises “write code once, get docs free.” Many popular frameworks (Spring Boot, ASP.NET Core, FastAPI, NestJS) have excellent code-first tooling with annotations that generate specs automatically. However, it trades design-phase benefits for implementation convenience.
Example
Spring Boot REST API: A Java developer annotates controller classes with @RestController, @GetMapping, @PostMapping, and uses @ApiOperation from Springdoc. When the app runs, Springdoc generates an OpenAPI spec at /v3/api-docs and serves Swagger UI at /swagger-ui.html - all automatically derived from code annotations.
FastAPI in Python: Python developers use type hints and Pydantic models to define endpoints. FastAPI introspects these types at runtime and auto-generates OpenAPI specs, interactive docs, and JSON Schema validation - no manual spec writing required.
ASP.NET Core: C# developers decorate controllers with attributes like [HttpGet], [ProducesResponseType], and XML comments. Swashbuckle parses these at build time, generating OpenAPI specs that power API documentation and client SDK generation.
NestJS TypeScript: Developers use decorators like @Get(), @Body(), @ApiProperty() on DTOs. NestJS’s Swagger module reflects on these decorators to generate OpenAPI specs matching the actual implementation.
Rapid Prototyping: A startup quickly builds an MVP by writing Flask or Express endpoints. Once routes work, they add minimal annotations and generate OpenAPI specs for frontend teams, avoiding upfront spec design when requirements are unclear.
Analogy
Write First, Outline Later: Code-first is like writing an essay by immediately drafting paragraphs, then using a tool to extract headings and generate a table of contents from the text. Contract-first is like outlining the essay structure first, getting feedback on the outline, then writing paragraphs to fill in each section.
Recorded Music: Code-first is like a band improvising a song in the studio, recording it, then someone transcribes it to sheet music. Contract-first is like writing the sheet music first, reviewing it with the band, then recording exactly what was written.
Construction Without Blueprints: Code-first is like skilled builders constructing a shed based on experience and intuition, then having an architect create as-built drawings documenting what was built. Contract-first is the traditional approach - architect draws blueprints first, builders follow them precisely.
Code Example
Spring Boot (Java) - Code-First with Annotations
import org.springframework.web.bind.annotation.*;
import io.swagger.v3.oas.annotations.*;
import io.swagger.v3.oas.annotations.media.*;
import io.swagger.v3.oas.annotations.responses.*;
import javax.validation.Valid;
import javax.validation.constraints.*;
@RestController
@RequestMapping("/api/users")
@Tag(name = "Users", description = "User management endpoints")
public class UserController {
@GetMapping("/{id}")
@Operation(summary = "Get user by ID", description = "Returns a single user")
@ApiResponses(value = {
@ApiResponse(responseCode = "200", description = "User found",
content = @Content(schema = @Schema(implementation = User.class))),
@ApiResponse(responseCode = "404", description = "User not found",
content = @Content(schema = @Schema(implementation = ErrorResponse.class)))
})
public User getUserById(@PathVariable String id) {
return userService.findById(id);
}
@PostMapping
@Operation(summary = "Create a new user")
@ApiResponse(responseCode = "201", description = "User created",
content = @Content(schema = @Schema(implementation = User.class)))
@ApiResponse(responseCode = "400", description = "Invalid input")
public User createUser(@Valid @RequestBody CreateUserRequest request) {
return userService.create(request);
}
}
@Schema(description = "User entity")
class User {
@Schema(description = "Unique user identifier", example = "123e4567-e89b-12d3-a456-426614174000")
private String id;
@Schema(description = "User's email address", example = "[email protected]")
@Email
private String email;
@Schema(description = "User's display name", example = "Alice Smith")
@NotBlank
@Size(min = 3, max = 50)
private String name;
// getters, setters
}
class CreateUserRequest {
@Email
@NotBlank
private String email;
@NotBlank
@Size(min = 3, max = 50)
private String name;
@Size(min = 12, max = 128)
@Pattern(regexp = "^(?=.*[A-Za-z])(?=.*\\d)[A-Za-z\\d@$!%*?&]{12,}$")
private String password;
// getters, setters
}
class ErrorResponse {
private String code;
private String message;
// getters, setters
}
Generated OpenAPI spec (automatic):
# This is auto-generated by Springdoc at /v3/api-docs
openapi: 3.0.1
info:
title: User API
version: 1.0.0
paths:
/api/users/{id}:
get:
tags:
- Users
summary: Get user by ID
description: Returns a single user
operationId: getUserById
parameters:
- name: id
in: path
required: true
schema:
type: string
responses:
'200':
description: User found
content:
application/json:
schema:
$ref: '#/components/schemas/User'
'404':
description: User not found
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
/api/users:
post:
tags:
- Users
summary: Create a new user
operationId: createUser
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: Invalid input
components:
schemas:
User:
type: object
description: User entity
properties:
id:
type: string
description: Unique user identifier
example: 123e4567-e89b-12d3-a456-426614174000
email:
type: string
format: email
description: User's email address
example: [email protected]
name:
type: string
minLength: 3
maxLength: 50
description: User's display name
example: Alice Smith
CreateUserRequest:
type: object
required: [email, name, password]
properties:
email:
type: string
format: email
name:
type: string
minLength: 3
maxLength: 50
password:
type: string
minLength: 12
maxLength: 128
pattern: "^(?=.*[A-Za-z])(?=.*\\d)[A-Za-z\\d@$!%*?&]{12,}$"
ErrorResponse:
type: object
properties:
code:
type: string
message:
type: string
FastAPI (Python) - Code-First with Type Hints
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel, EmailStr, Field
from typing import Optional
from datetime import datetime
app = FastAPI(title="User API", version="1.0.0")
# Pydantic models define schemas automatically
class CreateUserRequest(BaseModel):
email: EmailStr
name: str = Field(..., min_length=3, max_length=50)
password: str = Field(..., min_length=12, max_length=128)
class User(BaseModel):
id: str = Field(..., description="Unique user identifier")
email: EmailStr = Field(..., description="User's email address")
name: str = Field(..., description="User's display name")
created_at: datetime
class Config:
schema_extra = {
"example": {
"id": "123e4567-e89b-12d3-a456-426614174000",
"email": "[email protected]",
"name": "Alice Smith",
"created_at": "2024-01-01T10:00:00Z"
}
}
@app.get("/api/users/{user_id}",
response_model=User,
summary="Get user by ID",
responses={404: {"description": "User not found"}})
async def get_user(user_id: str):
"""Returns a single user by ID"""
user = await user_service.find_by_id(user_id)
if not user:
raise HTTPException(status_code=404, detail="User not found")
return user
@app.post("/api/users",
response_model=User,
status_code=201,
summary="Create a new user")
async def create_user(request: CreateUserRequest):
"""Creates a new user account"""
return await user_service.create(request)
# FastAPI auto-generates OpenAPI at /openapi.json
# Serves interactive docs at /docs (Swagger UI)
# Serves alternative docs at /redoc (ReDoc)
Diagram
graph TB
subgraph "Code-First Flow"
START[Write Implementation
With Annotations]
START --> BUILD[Build/Runtime
Process]
BUILD --> EXTRACT[Extract Annotations
Reflection/AST]
EXTRACT --> GEN_SPEC[Generate OpenAPI Spec
Automatic]
GEN_SPEC --> DOCS[Swagger UI
Documentation]
GEN_SPEC --> SDK[Generate SDKs
From Spec]
GEN_SPEC --> VALIDATE[Validation
From Spec]
subgraph "Challenges"
DRIFT[Spec Drift Risk
If annotations incomplete]
DESIGN[No Design Phase
Stakeholder Review]
REFACTOR[Refactoring Impact
Breaks API]
end
GEN_SPEC -.Risks.-> DRIFT
START -.Risks.-> DESIGN
START -.Risks.-> REFACTOR
end
style START fill:#87CEEB
style GEN_SPEC fill:#90EE90
style DRIFT fill:#FFB6C6
style DESIGN fill:#FFB6C6
Best Practices
- Annotate thoroughly - Don’t rely on defaults, explicitly annotate responses, errors, examples
- Version from the start - Even code-first benefits from explicit versioning (e.g.,
/v1/users) - Validate generated spec - Review auto-generated OpenAPI for completeness and accuracy
- Export and version specs - Commit generated specs to git for tracking API evolution
- Use framework validation - Let framework annotations handle input validation (e.g.,
@Valid) - Document examples in code - Use annotation features to provide realistic examples
- Combine with contract testing - Generate spec, then test implementation against it
- Consider hybrid approach - Start code-first for prototyping, switch to contract-first for production
Common Mistakes
Incomplete annotations: Relying on framework defaults without explicit annotations for error responses, authentication, or examples. Generated specs have gaps.
Ignoring generated spec: Auto-generating the spec but never reviewing it. Clients might get poor documentation or incorrect schemas.
No spec versioning: Generating specs on-the-fly without committing them to version control. No history of API changes, hard to detect breaking changes.
Refactoring breaks API: Renaming a class field breaks the API response schema without warning. Code-first lacks early warning systems.
Framework lock-in: Heavy use of framework-specific annotations makes migration difficult. Spec is not framework-agnostic.
Missing design review: Skipping stakeholder review of API design because “the code is the spec.” Design flaws discovered late.
Annotation overload: Code becomes cluttered with so many documentation annotations it’s hard to read the business logic.