← Back to the section

When you write a REST API in Go, there are two levels of questions. The first is how to correctly describe the API in an OpenAPI specification: what operationId is, why tags are needed, why path parameters must be unique. The second is which mistakes come up most often and how to avoid them.

This article answers both questions.

Why Go has no automatic OpenAPI generation

Java frameworks like Spring have plugins that read annotations and build the OpenAPI specification themselves. Go has no such built-in mechanism — its language structures are much simpler, without reflection at the annotation level.

There are two workable approaches:

Option A — swaggo/swag. You write comments in a special format next to the handler, run swag init, and get docs/swagger.json. Plus: the spec always lives next to the code. Minus: the comments duplicate the Go structs, so you have to keep them in sync.

Option B — a manual spec. You maintain an openapi.yaml next to main.go. The Go structs are the source of truth; review checks that the spec matches them.

Both options work. The main thing is to pick one and stick to it across the whole service.

operationId: a name for every operation

Imagine you're writing an SDK for your API. An SDK generator (for example, openapi-generator) will take the operationId and turn it into a method name. If there's no operationId, the generator will make up something itself, like postApiV1OrdersOrderIdConfirm. That's unreadable and will break when the route is renamed.

The rule is simple: operationId is camelCase, in the form action + resource.

// createOrder godoc
// @Summary      Create an order
// @Tags         Orders
// @Accept       json
// @Produce      json
// @Param        body body CreateOrderRequest true "Order parameters"
// @Success      201 {object} OrderResponse
// @Failure      400 {object} ValidationProblem
// @Failure      409 {object} ProblemDetails
// @Router       /api/v1/orders [post]
// @operationId  createOrder
func createOrder(w http.ResponseWriter, r *http.Request) { ... }

// confirmOrder godoc
// @Summary      Confirm an order
// @Tags         Orders
// @Param        orderId path string true "Order ID"
// @Success      200 {object} OrderResponse
// @Failure      404 {object} ProblemDetails
// @Router       /api/v1/orders/{orderId}/confirm [post]
// @operationId  confirmOrder
func confirmOrder(w http.ResponseWriter, r *http.Request) { ... }

Naming convention:

OperationoperationId
GET /ordersgetOrders
GET /orders/{id}getOrder
POST /orderscreateOrder
PUT /orders/{id}updateOrder
PATCH /orders/{id}patchOrder
DELETE /orders/{id}deleteOrder
POST /orders/{id}/confirmconfirmOrder
POST /orders/searchsearchOrders
GET /products/{id}/price-historygetProductPriceHistory

Tags: grouping by resource

Swagger UI shows operations by tag. Without tags you get a flat list of 50+ methods with no navigation. Rule: one tag per resource, plural and capitalized (Orders, Products, Customers). Action endpoints (/confirm, /cancel) get the tag of the parent resource.

// @Tags Orders       ← createOrder, getOrder, confirmOrder, cancelOrder
// @Tags Products     ← getProduct, createProduct, listProductReviews
// @Tags Customers    ← getCustomer, verifyCustomer

In the chi router, tags correspond to r.Route blocks:

r.Route("/api/v1", func(r chi.Router) {
    r.Route("/orders", func(r chi.Router) {          // Orders tag
        r.Get("/", listOrders)
        r.Post("/", createOrder)
        r.Route("/{orderId}", func(r chi.Router) {
            r.Get("/", getOrder)
            r.Post("/confirm", confirmOrder)          // also Orders tag
        })
    })
    r.Route("/products", func(r chi.Router) {        // Products tag
        r.Get("/", listProducts)
        r.Get("/{id}", getProduct)
    })
})

Path parameters: {id} in chi and unique names in OpenAPI

There's a subtlety with nested routes. If you write /orders/{id}/items/{id}, chi returns a single value for both {id}. That's a routing bug.

Rule: path parameters are named uniquely across the whole route.

// Correct
r.Get("/orders/{orderId}/items/{itemId}", getOrderItem)

// Wrong — two {id} in one path
r.Get("/orders/{id}/items/{id}", getOrderItem)

In the OpenAPI spec, parameters are also named uniquely — this is a requirement of Swagger UI and Redoc: they don't render an operation correctly if two parameters in the same path have the same name.

/api/v1/orders/{orderId}/items/{itemId}:
  get:
    operationId: getOrderItem
    parameters:
      - name: orderId
        in: path
        required: true
        schema:
          type: string
          format: uuid
      - name: itemId
        in: path
        required: true
        schema:
          type: string
          format: uuid

summary and description

summary is required, up to 80 characters. description — only if the logic isn't obvious: there's a side effect, behavior depends on the role, or the semantics aren't clear from the name.

// getCustomerCardLimit godoc
// @Summary      Get the customer's card limit
// @Description  Returns the current spending limit. The limit is recalculated at night;
//               during the day a cached value is returned.
// @Tags         Customers
// @Param        customerId path string true "Customer ID"
// @Success      200 {object} CardLimitResponse
// @Router       /api/v1/customers/{customerId}/card-limit [get]
// @operationId  getCustomerCardLimit
func getCustomerCardLimit(w http.ResponseWriter, r *http.Request) { ... }

An empty description is better than duplicating the summary.

Go structs as the source of the contract

Regardless of the chosen approach (swaggo or a manual spec), the response-type structs define the contract:

type OrderResponse struct {
    OrderID   string         `json:"orderId"`
    Status    string         `json:"status"`      // NEW | CONFIRMED | SHIPPED
    CreatedAt time.Time      `json:"createdAt"`
    TotalRub  int64          `json:"totalRub"`    // kopecks
    Items     []ItemResponse `json:"items"`
    Note      string         `json:"note,omitempty"`  // absent if empty
}

type PageResponse[T any] struct {
    Content []T `json:"content"`
    Page    int `json:"page"`
    Size    int `json:"size"`
    Total   int `json:"total"`
}

omitempty on Note means: the field is absent from the JSON when empty — no "note": null, no "note": "". This is the correct behavior for optional string fields.

Common mistakes and how to fix them

URLs and routing

A verb in the URL for CRUD. People write /getOrders, /createProduct — don't do that. The verb is the HTTP method: GET /orders, POST /products.

Wrong case in the path. URL segments are kebab-case: /order-items, not /orderItems and not /order_items.

Trailing slash. In chi, r.Get("/orders/", ...) and r.Get("/orders", ...) are different routes. Register without a trailing slash.

ID in the request body. If the resource ID is in the path, pull it out via chi.URLParam(r, "orderId"), not from the body.

Three levels of nesting. A route like /orders/{id}/items/{id}/shipments is hard to read and hard to version. If you need a third level of nesting, it's better to extract it into a separate resource: GET /shipments?itemId={id}.

GET with a side effect. GET /orders/{id}/confirm is wrong. GET must be safe: repeating the call doesn't change state. Actions are done via POST: POST /orders/{id}/confirm.

Versioning

Version in a query parameter. ?version=2 — don't do this. The version goes in the path: /api/v2/.

Minor version. /api/v1.2/ is wrong. The version changes only on an incompatible change (breaking change), and is always a whole number.

A new version for the sake of an optional field. Adding an optional field is a backward-compatible change. Just add the field with omitempty in the current version.

Query parameters

Comma-separated arrays. ?status=NEW,PAID is awkward to parse and breaks URL encoding. Correct: repeat the parameter — ?status=NEW&status=PAID, pull it out via r.Form["status"].

Zero-based page numbering. page=0 is unintuitive for clients. Start with page=1.

snake_case in parameters. Parameters are camelCase: createdFrom, pageSize, not created_from, page_size.

Business logic in the query. ?action=cancel is a hidden action endpoint. Correct: POST /orders/{id}/cancel.

JSON and responses

null in fields of a 2xx response. If a field is optional, use string with omitempty, not *string. Pointers (*string) pull null into the JSON — leave them only for PATCH requests, where null means "clear the field".

Envelope wrapping. {"data": {...}, "success": true} is an extra level of nesting. Return a flat object.

null instead of an empty array. If an order has no items, return "items": [], not "items": null. In Go, initialize the slice explicitly: items := make([]ItemResponse, 0).

float64 for money. In Go, money is stored and transmitted in whole numbers (minor units — kopecks, cents): int64.

Errors

Wrong Content-Type for errors. For errors, use application/problem+json (RFC 9457), not application/json.

about:blank in the type field. The type field in ProblemDetails should contain a URI that identifies the error type: urn:problem:order-service:not-found. about:blank is a placeholder, not informative.

HTTP 422 for validation errors. 422 isn't part of the standard REST API set of codes. For validation errors, use 400 Bad Request with code: VALIDATION_ERROR and a list of violations in the violations field.

A string from the database in detail. Never put the error text from pgx/sqlc directly into the response — it may contain internal schema details. In detail, put a general phrase in plain language.

Different error structures in different handlers. That's chaos for clients. One unified httperr.Write(w, r, err) across the whole service.

Headers and additional topics

X- prefix in custom headers. X-Request-Id is an obsolete convention (RFC 6648 deprecated it in 2012). Use a domain prefix: Shop-Request-Id.

429 without Retry-After. If you return 429 Too Many Requests, the middleware should set the Retry-After and RateLimit-* headers. Without them the client doesn't know when to retry the request.

deprecated: true without Sunset. If you mark an operation as deprecated in OpenAPI, add middleware with the Sunset and Deprecation headers so that clients get the signal in responses, not just in the specification.

In short

  • operationId — camelCase, in the form action + resource (createOrder, confirmOrder). Without it, SDK generators create unreadable names.
  • One tag per resource, plural (Orders, Products). Action endpoints get the parent resource's tag.
  • Path parameters are named uniquely: {orderId}, {itemId} — not two {id} in the same route.
  • summary is required; description — only if there's something to explain.
  • Optional fields in response structs — omitempty, not pointers. *T only in PATCH requests.
  • Validation errors — 400, not 422. Content-Type: application/problem+json, not application/json.
  • Money — int64 in minor units. float64 is not used for money.
  • A single httperr.Write across the whole service — the client gets a predictable error structure.
  • URL and resources — kebab-case, nesting, chi.URLParam
  • Alias and action endpoints — me, action endpoints, POST /confirm
  • Versioning — breaking change, chi r.Route("/api/v2", ...)
  • Query parameters — camelCase, r.Form["status"], cursor
  • JSON and response format — omitempty, PageResponse, int64
  • Errors RFC 9457 — ProblemDetails, httperr.Write, violations