OpenAPI First API Design in Go, oapi-codegen in 2024
TL;DR — OpenAPI-first with oapi-codegen 2.x is the most pleasant way to ship REST APIs in Go in 2024. Spec in version control, types and server interface generated, request validation free at the edge. The discipline is keeping the spec authoritative and never hand-editing generated code.
I’ve gone back and forth on OpenAPI several times in my career. Pre-3.0 (when it was still Swagger 2.0), it was painful. The 3.0 generation of tooling was promising but rough. In 2024, with oapi-codegen 2.x and OpenAPI 3.1, I think it’s finally the default I’d recommend for any new Go REST service.
This post is the workflow I run on every new project. It’s not a tutorial on writing OpenAPI — there are good resources for that — and it’s not a pitch for OpenAPI as a concept. It’s the specific Go toolchain that makes “spec-first” actually work day to day, with the operational details I find missing from other writeups.
If you’re still deciding between REST and other API styles, my piece on GraphQL vs REST in 2024 covers the framing question. This post assumes you’ve already picked REST.
Why spec-first, why oapi-codegen
The two opposite ways to build a REST API in Go:
- Code-first. Write handlers, generate spec from them (via comments, swag, or fiber/echo annotations).
- Spec-first. Write the OpenAPI document, generate types and a server interface, implement against the interface.
I have shipped both. Spec-first wins for me because the OpenAPI document is a better artifact for review than Go code. Product, frontend, and security people can read OpenAPI. They can’t (or won’t) read your handler code. A PR that changes the API surface is a PR that changes a YAML file, and that’s reviewable by humans who aren’t Go engineers.
oapi-codegen is the Go tool I’ve settled on. The 2.x line generates types, server interfaces (for chi, echo, gin, gorilla, fiber, and net/http), client SDKs, and kin-openapi validators. It’s actively maintained, the output is idiomatic, and the configuration is straightforward.
The OpenAPI specification at openapis.org is the canonical reference if you need to look up details on 3.1 features.
A spec that ages well
# api/openapi.yaml
openapi: 3.1.0
info:
title: Orders API
version: 1.0.0
servers:
- url: https://api.example.com/v1
paths:
/orders:
get:
operationId: listOrders
tags: [orders]
parameters:
- $ref: '#/components/parameters/PageSize'
- $ref: '#/components/parameters/PageToken'
- name: customer_id
in: query
schema: { type: string, format: uuid }
responses:
'200':
description: page of orders
content:
application/json:
schema: { $ref: '#/components/schemas/OrderPage' }
'400': { $ref: '#/components/responses/BadRequest' }
'401': { $ref: '#/components/responses/Unauthorized' }
post:
operationId: createOrder
tags: [orders]
parameters:
- name: Idempotency-Key
in: header
required: true
schema: { type: string, minLength: 1, maxLength: 255 }
requestBody:
required: true
content:
application/json:
schema: { $ref: '#/components/schemas/CreateOrderRequest' }
responses:
'201':
description: order created
content:
application/json:
schema: { $ref: '#/components/schemas/Order' }
'400': { $ref: '#/components/responses/BadRequest' }
'409': { $ref: '#/components/responses/Conflict' }
/orders/{id}:
get:
operationId: getOrder
tags: [orders]
parameters:
- name: id
in: path
required: true
schema: { type: string, format: uuid }
responses:
'200':
description: order
content:
application/json:
schema: { $ref: '#/components/schemas/Order' }
'404': { $ref: '#/components/responses/NotFound' }
components:
parameters:
PageSize:
name: page_size
in: query
schema: { type: integer, minimum: 1, maximum: 100, default: 25 }
PageToken:
name: page_token
in: query
schema: { type: string }
responses:
BadRequest:
description: bad request
content:
application/problem+json:
schema: { $ref: '#/components/schemas/Problem' }
Unauthorized:
description: unauthorized
content:
application/problem+json:
schema: { $ref: '#/components/schemas/Problem' }
NotFound:
description: not found
content:
application/problem+json:
schema: { $ref: '#/components/schemas/Problem' }
Conflict:
description: conflict
content:
application/problem+json:
schema: { $ref: '#/components/schemas/Problem' }
schemas:
Order:
type: object
required: [id, customer_id, items, total, status, created_at]
properties:
id: { type: string, format: uuid }
customer_id: { type: string, format: uuid }
items:
type: array
items: { $ref: '#/components/schemas/LineItem' }
total: { $ref: '#/components/schemas/Money' }
status:
type: string
enum: [pending, paid, fulfilled, cancelled]
created_at: { type: string, format: date-time }
LineItem:
type: object
required: [sku, quantity, unit_price]
properties:
sku: { type: string }
quantity: { type: integer, minimum: 1 }
unit_price: { $ref: '#/components/schemas/Money' }
Money:
type: object
required: [currency_code, amount]
properties:
currency_code: { type: string, minLength: 3, maxLength: 3 }
amount: { type: string, pattern: '^-?\d+(\.\d+)?$' }
CreateOrderRequest:
type: object
required: [customer_id, items]
properties:
customer_id: { type: string, format: uuid }
items:
type: array
minItems: 1
items: { $ref: '#/components/schemas/LineItem' }
OrderPage:
type: object
required: [orders]
properties:
orders:
type: array
items: { $ref: '#/components/schemas/Order' }
next_page_token: { type: string }
Problem:
type: object
required: [type, title, status]
properties:
type: { type: string, format: uri }
title: { type: string }
status: { type: integer }
detail: { type: string }
instance: { type: string }
Patterns worth lifting:
operationIdon every operation. This becomes the Go function name. Without it, oapi-codegen generates something likeGetOrdersOrderId. With it, you getGetOrder. Always set it.$refreusable parameters and responses.PageSize,BadRequest,Unauthorizeddefined once. When you add a new endpoint, you reuse them. Consistency for free.application/problem+jsonfor errors. RFC 7807. Every error response should be aProblemobject. Not a custom error envelope.- Money as string + currency. Don’t put monetary amounts in
number. JSON numbers are doubles. Use a decimal string. Idempotency-Keyas a required header onPOST. Built into the spec. Clients get a clear error if they forget it.
Generation config
# oapi-codegen.yaml
package: api
output: internal/api/api.gen.go
generate:
models: true
strict-server: true
embedded-spec: true
output-options:
skip-prune: true
strict-server: true is the killer feature in oapi-codegen 2.x. It generates a server interface where each method takes a typed request struct and returns a typed response. Compare:
// Without strict-server (the old way)
func (s *Server) GetOrder(w http.ResponseWriter, r *http.Request, id string) {
// You write JSON marshaling, error handling, status codes...
}
// With strict-server (the 2.x way)
func (s *Server) GetOrder(ctx context.Context, req GetOrderRequestObject) (GetOrderResponseObject, error) {
order, err := s.repo.Find(ctx, req.Id)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
return GetOrder404JSONResponse{Problem{
Type: "https://example.com/errors/not-found",
Title: "Order not found",
Status: 404,
}}, nil
}
return nil, err
}
return GetOrder200JSONResponse(*order), nil
}
The strict-server version is type-safe at every layer. You cannot accidentally return a 200 body in a 404, you cannot forget to write the status code, and adding a new response in the spec is a compile error in your handler until you handle it. This is the entire reason to use spec-first.
embedded-spec: true embeds the OpenAPI document in the binary, so you can serve it from your service at /openapi.yaml. Clients can fetch the spec from the service itself, no separate distribution channel.
Validation at the edge
oapi-codegen integrates with kin-openapi for request validation. Add it as middleware:
import (
middleware "github.com/oapi-codegen/nethttp-middleware"
"github.com/yourorg/orders/internal/api"
)
func main() {
swagger, err := api.GetSwagger()
if err != nil {
log.Fatal(err)
}
swagger.Servers = nil // don't validate Host header
mux := http.NewServeMux()
handler := api.HandlerWithOptions(
api.NewStrictHandler(server, nil),
api.StdHTTPServerOptions{
BaseRouter: mux,
Middlewares: []api.MiddlewareFunc{
middleware.OapiRequestValidator(swagger),
},
},
)
http.ListenAndServe(":8080", handler)
}
This validates every incoming request against the spec — required fields, type constraints, enum values, min/max — before it reaches your handler. Garbage requests return 400 Bad Request automatically with a useful error message. Your handler only ever sees valid input.
CI discipline
The whole workflow falls apart if the spec and the generated code drift. In CI, every PR runs:
.PHONY: generate
generate:
oapi-codegen -config oapi-codegen.yaml api/openapi.yaml
.PHONY: ci-spec
ci-spec: generate
@git diff --exit-code internal/api/api.gen.go || \
(echo "generated code out of date, run 'make generate'"; exit 1)
@redocly lint api/openapi.yaml
The git diff --exit-code is the load-bearing line. It ensures that if you edit the spec, you’ve also regenerated the code, and reviewers see both. If you edit the generated code without changing the spec, CI fails.
redocly lint (or spectral lint) catches spec issues — missing descriptions, inconsistent naming, unreachable error codes. Run it. It’s free quality.
Common Pitfalls
- Hand-editing generated files. It’s tempting when you need a custom serialization or a special header. Don’t. Add an option to the generation, override via a custom template, or use middleware. Editing
*.gen.gomakes regeneration destructive. - Skipping
strict-server. Without it, you lose most of the type-safety value. There’s no reason not to use it in new code. - Using
additionalProperties: truecasually. It means “accept any other fields”. That’s not what you want. Either set it tofalse(strict, recommended) or define every field. - No examples in the spec. Every schema and endpoint should have an
example. They make your docs better and they help your generated mock servers. - Inconsistent error envelopes. Pick one error format (I recommend Problem+JSON) and use it for every error response across the API. No custom error shapes per endpoint.
- Forgetting to version.
info.versionshould bump on every release.servers.urlshould include the major version. When you make a breaking change, you bump to v2 in both places.
Wrapping Up
OpenAPI-first with oapi-codegen 2.x has been the most productive REST workflow I’ve used in 10+ years of writing Go services. The strict-server pattern eliminates an entire category of handler bugs, request validation at the edge eliminates another, and the spec-as-source-of-truth makes API review a cross-functional activity rather than a Go engineer’s concern.
The thing I’d encourage anyone adopting this to invest in first: CI that fails when spec and code drift. Without it, the workflow rots within a quarter. With it, the spec stays authoritative and the generated code stays in sync, and the whole thing keeps paying off as the API grows.