background-shape
OpenAPI First API Design in Go, oapi-codegen in 2024
July 22, 2024 · 8 min read · by Muhammad Amal programming

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:

  1. Code-first. Write handlers, generate spec from them (via comments, swag, or fiber/echo annotations).
  2. 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:

  • operationId on every operation. This becomes the Go function name. Without it, oapi-codegen generates something like GetOrdersOrderId. With it, you get GetOrder. Always set it.
  • $ref reusable parameters and responses. PageSize, BadRequest, Unauthorized defined once. When you add a new endpoint, you reuse them. Consistency for free.
  • application/problem+json for errors. RFC 7807. Every error response should be a Problem object. 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-Key as a required header on POST. 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.go makes 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: true casually. It means “accept any other fields”. That’s not what you want. Either set it to false (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.version should bump on every release. servers.url should 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.