A REST API is not just a set of routes. In an enterprise system, it is a promise made to every client that depends on your service: browser applications, mobile apps, automation jobs, partner integrations, internal services, QA tools, and operations dashboards.
That promise is expensive to change after people start relying on it. A renamed field can break a mobile release. A removed endpoint can stop an integration. A vague error response can cause retries that make an incident worse. This is why serious API work starts before the first handler is written.
An API-First workflow treats the API contract as the design center. You define the contract, review it, generate code from it, and then implement the business behavior behind it. In Go, this approach works especially well with a hexagonal architecture, where the HTTP layer is an adapter around a clean domain instead of the place where business rules live.
The Problem
Imagine a fleet dispatch service. Clients need to create dispatch tasks, list them by state, and fetch one task by ID. The service may later change its database, refactor its business logic, or add a new transport such as gRPC. Clients should not care about any of that. They only care that the REST API remains stable.
The difficult part is not creating a route like /dispatches/{id}. The difficult part is keeping the public contract clear while the implementation changes over time.
A practical API design must answer these questions:
- What resources does the service expose?
- Which operations are safe reads and which operations change state?
- Which request fields are required, optional, or planned for later?
- Which status codes tell clients what happened?
- What structured error body should clients parse?
- How can the API evolve without forcing existing clients to rewrite their code?
The goal is to publish a contract that is small enough to maintain, clear enough to implement, and stable enough to survive real production usage.
What API-First Means
API-First means the external contract is designed and reviewed before the implementation becomes the source of truth. In practice, that contract is usually written as an OpenAPI specification.
OpenAPI describes REST APIs in a machine-readable file. Developers can read it, but tools can also use it. A good OpenAPI file can drive documentation, generated Go models, generated server interfaces, mock servers, client generation, and compatibility checks in CI.
In a Go service with clean boundaries, the OpenAPI contract belongs to the HTTP adapter. The domain should not import Gin, know about query parameters, depend on response codes, or carry wire-format tags just because the REST API needs them.
A useful mental model looks like this:
Client
|
v
OpenAPI contract
|
v
HTTP adapter: routing, decoding, encoding, status codes
|
v
Application port: interface used by the adapter
|
v
Domain service: business decisions and rules
The HTTP adapter translates between the outside world and the domain. The domain stays focused on business language such as dispatch tasks, vehicles, routes, states, and scheduling rules.
Designing Resources Before Writing Handlers
REST APIs work best when resources are modeled as business nouns and operations are expressed through HTTP methods. For the fleet dispatch example, dispatches is a resource. The client can list dispatches, create a new dispatch, or fetch a specific dispatch.
A small contract may start like this:
openapi: 3.0.3
info:
title: Fleet Dispatch API
version: 1.0.0
paths:
/dispatches:
get:
operationId: listDispatches
parameters:
- name: state
in: query
required: false
schema:
type: string
responses:
"200":
description: Dispatch tasks returned
post:
operationId: createDispatch
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/CreateDispatchRequest"
responses:
"201":
description: Dispatch task created
"422":
$ref: "#/components/responses/ValidationProblem"
/dispatches/{id}:
get:
operationId: getDispatch
parameters:
- name: id
in: path
required: true
schema:
type: string
responses:
"200":
description: Dispatch task returned
"404":
$ref: "#/components/responses/NotFoundProblem"
components:
schemas:
Dispatch:
type: object
required:
- id
- vehicleCode
- routeCode
- state
properties:
id:
type: string
vehicleCode:
type: string
routeCode:
type: string
state:
type: string
metadata:
$ref: "#/components/schemas/EntityMetadata"
EntityMetadata:
type: object
properties:
createdAt:
type: string
updatedAt:
type: string
CreateDispatchRequest:
type: object
required:
- vehicleCode
- routeCode
properties:
vehicleCode:
type: string
routeCode:
type: string
responses:
ValidationProblem:
description: Request content could not be processed
NotFoundProblem:
description: Requested resource was not found
This is intentionally small. The contract exposes what clients need now: create, list, and fetch. It also shows composition through EntityMetadata, which keeps shared fields reusable without building complicated inheritance-like structures.
A careful contract is usually better than a large one. Every public field becomes a commitment. Every route becomes a long-term maintenance obligation.
Keeping the Generated Code at the Edge
Generated code is useful, but it should not become your business model. The clean approach is to place generated DTOs (Data Transfer Objects) in the HTTP adapter and convert them into domain types before calling the core service.
A practical layout can look like this:
fleet/
|-- api/
| |-- contract.yaml
| |-- models.cfg.yaml
| |-- gin-server.cfg.yaml
| |-- generator.go
|-- internal/
| |-- adapters/
| | |-- http/
| | | |-- api/
| | | | |-- models.gen.go
| | | | |-- server.gen.go
| | | |-- converter/
| | | | |-- dispatch_converter.go
| | | |-- handler/
| | | | |-- dispatch_handler.go
| |-- models/
| | |-- dispatch.go
| |-- ports/
| | |-- dispatch_service.go
Each folder has a clear purpose:
apiholds the OpenAPI file and generator configuration.internal/adapters/http/apiholds generated models and server interfaces.internal/adapters/http/convertermaps generated DTOs to domain models.internal/adapters/http/handlerimplements the generated server interface.internal/modelscontains domain structures with no HTTP dependency.internal/portsdefines interfaces that the adapter can call.
This structure protects the service from coupling. When the API contract changes, the HTTP adapter changes. When the business model changes, the domain changes. The two are connected deliberately through converters.
Generating Models and Server Interfaces
A generator removes repetitive work and keeps the implementation aligned with the OpenAPI file. With oapi-codegen, you can generate Go models and Gin server interfaces from the contract.
Install the required packages:
go get github.com/gin-gonic/gin
go get github.com/oapi-codegen/runtime
go get github.com/oapi-codegen/oapi-codegen/v2/pkg/codegen
Use one configuration for models:
package: api
generate:
models: true
output: ../internal/adapters/http/api/models.gen.go
Use a separate configuration for the Gin server interface and route registration helpers:
package: api
generate:
gin-server: true
embedded-spec: true
output: ../internal/adapters/http/api/server.gen.go
Keeping these files separate is practical. Schema changes may only require model regeneration. Route changes may require server regeneration. You do not have to treat all generated output as one big artifact.
Now add a generator file in the api folder:
//go:build generate
package main
//go:generate go run github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen --config=models.cfg.yaml contract.yaml
//go:generate go run github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen --config=gin-server.cfg.yaml contract.yaml
import (
_ "github.com/oapi-codegen/oapi-codegen/v2/pkg/codegen"
_ "gopkg.in/yaml.v2"
)
Run generation with one command:
go generate ./api
The build tag keeps this helper out of normal application builds. The blank imports make sure the generator dependencies are tracked by the module.
Converting Between API DTOs and Domain Models
Generated API models represent the wire contract. Domain models represent business concepts. They often look similar at the beginning, but they should not be treated as the same thing.
A domain model can stay free of wire-format tags:
package models
import "time"
type DispatchTask struct {
ID string
VehicleCode string
RouteCode string
StartsAt time.Time
State string
}
The application port describes what the HTTP adapter needs from the core:
package ports
import (
"context"
"example.com/fleet/internal/models"
)
type DispatchService interface {
List(ctx context.Context, state string) ([]models.DispatchTask, error)
Create(ctx context.Context, task models.DispatchTask) (models.DispatchTask, error)
Get(ctx context.Context, id string) (models.DispatchTask, error)
}
The converter is the firewall between generated DTOs and the domain:
package converter
import (
"example.com/fleet/internal/adapters/http/api"
"example.com/fleet/internal/models"
)
func DispatchToDomain(input api.Dispatch) models.DispatchTask {
return models.DispatchTask{
ID: input.Id,
VehicleCode: input.VehicleCode,
RouteCode: input.RouteCode,
State: string(input.State),
}
}
func DispatchFromDomain(task models.DispatchTask) api.Dispatch {
return api.Dispatch{
Id: task.ID,
VehicleCode: task.VehicleCode,
RouteCode: task.RouteCode,
State: api.DispatchState(task.State),
}
}
The converter may feel like extra code, but it buys long-term freedom. The domain can evolve without matching every field name in the API. The API can expose a stable shape while internal models are refactored for better business clarity.
Implementing the HTTP Adapter
The generated server interface is the HTTP contract that the handler must satisfy. The handler should decode request information, call the service port, translate errors, and write responses. It should not contain business rules.
A simplified handler can look like this:
package handler
import (
"net/http"
"github.com/gin-gonic/gin"
"example.com/fleet/internal/adapters/http/api"
"example.com/fleet/internal/adapters/http/converter"
"example.com/fleet/internal/ports"
)
type DispatchHandler struct {
service ports.DispatchService
}
func NewDispatchHandler(service ports.DispatchService) *DispatchHandler {
return &DispatchHandler{service: service}
}
func (h *DispatchHandler) ListDispatches(c *gin.Context, params api.ListDispatchesParams) {
state := ""
if params.State != nil {
state = string(*params.State)
}
items, err := h.service.List(c.Request.Context(), state)
if err != nil {
writeProblem(c, http.StatusInternalServerError, "dispatch-list-failed", "Dispatch list failed", err.Error())
return
}
response := make([]api.Dispatch, 0, len(items))
for _, item := range items {
response = append(response, converter.DispatchFromDomain(item))
}
c.JSON(http.StatusOK, response)
}
func (h *DispatchHandler) GetDispatch(c *gin.Context, id string) {
item, err := h.service.Get(c.Request.Context(), id)
if err != nil {
writeProblem(c, http.StatusNotFound, "dispatch-not-found", "Dispatch not found", err.Error())
return
}
c.JSON(http.StatusOK, converter.DispatchFromDomain(item))
}
func (h *DispatchHandler) CreateDispatch(c *gin.Context) {
writeProblem(c, http.StatusNotImplemented, "not-ready", "Operation not implemented", "The endpoint is published but not available yet.")
}
var _ api.ServerInterface = (*DispatchHandler)(nil)
That final compile-time assertion is small but valuable. If the OpenAPI contract changes and the generated interface gets a new method, the handler stops compiling until you implement the missing method. This turns contract drift into a build failure instead of a production surprise.
Wiring the Router
The router connects the generated registration function to the handler. Operational endpoints such as /healthz can stay outside the public OpenAPI contract when they exist only for monitoring.
package httpserver
import (
"fmt"
"net/http"
"time"
"github.com/gin-gonic/gin"
"example.com/fleet/internal/adapters/http/api"
"example.com/fleet/internal/adapters/http/handler"
"example.com/fleet/internal/configs"
"example.com/fleet/internal/ports"
)
func Run(cfg configs.Config, svc ports.DispatchService) error {
router := gin.New()
router.GET("/healthz", func(c *gin.Context) {
c.String(http.StatusOK, "ok")
})
dispatchHandler := handler.NewDispatchHandler(svc)
api.RegisterHandlers(router, dispatchHandler)
server := &http.Server{
Addr: fmt.Sprintf(":%d", cfg.Server.Port),
Handler: router,
ReadTimeout: time.Duration(cfg.Server.ReadTimeoutSec) * time.Second,
WriteTimeout: time.Duration(cfg.Server.WriteTimeoutSec) * time.Second,
}
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
return fmt.Errorf("server failed on %s: %w", server.Addr, err)
}
return nil
}
This gives the service an early runnable shape. Front-end developers and QA engineers can interact with the HTTP surface before every domain feature is complete. That is one of the strongest practical benefits of API-First work: implementation can proceed in parallel without guessing the contract.
For a production server, also include graceful shutdown and signal handling so in-flight requests have time to finish when the service is stopped.
Struct Tags Are Public API Details
When Go structs cross a REST boundary, struct tags decide how fields are encoded and decoded. A field tag may look small, but it affects every client that reads or sends data.
type DispatchResponse struct {
ID string `json:"id"`
VehicleCode string `json:"vehicleCode"`
RouteCode string `json:"routeCode"`
State string `json:"state"`
TraceID *string `json:"traceId,omitempty"`
SecretNote string `json:"-"`
}
Important rules:
- Only exported fields are encoded by Go's standard encoder. A lowercase field is ignored even if it has a tag.
- The tag name controls the public field name. Renaming
VehicleCodein Go does not have to renamevehicleCodein the API. omitemptyremoves zero values such as empty strings, zero numbers, false booleans, nil pointers, and empty slices or maps.json:"-"prevents a field from being exposed.- Tags are ordinary strings, so a typo can silently change the public contract.
Optional fields deserve special attention. In many APIs, missing and explicitly empty are different decisions. For partial updates, pointers make that intent visible:
type DispatchPatch struct {
VehicleCode *string `json:"vehicleCode,omitempty"`
RouteCode *string `json:"routeCode,omitempty"`
Active *bool `json:"active,omitempty"`
}
func applyPatch(current *DispatchResponse, patch DispatchPatch) {
if patch.VehicleCode != nil {
current.VehicleCode = *patch.VehicleCode
}
if patch.RouteCode != nil {
current.RouteCode = *patch.RouteCode
}
if patch.Active != nil {
if *patch.Active {
current.State = "active"
} else {
current.State = "inactive"
}
}
}
In this pattern, nil means the client did not provide the field. A non-nil pointer means the client made an explicit choice, even when the value is false or empty. This avoids fragile sentinel values such as -1 or an empty string with a special meaning.
Avoiding Overcommitment
A public API should not expose fields just because they might be useful someday. Extra fields create extra promises. If a client reads a field, removing it later is a breaking change. If a client depends on a loose behavior, tightening it later may cause outages.
Use a conservative rule: expose what clients need now, and keep everything else inside the service.
This applies to fields, endpoints, filters, response shapes, and error types. A future database column is not automatically a future API field. Internal model structure is not automatically public response structure. A route is not harmless just because it is easy to add.
The API contract should be smaller than the code behind it. A narrow contract gives you more internal freedom.
Evolving the API Without Breaking Clients
API evolution has two common styles: forward-compatible design and backward-compatible design.
Forward-compatible design publishes the intended long-term shape early. Some operations may be planned but not implemented yet. When this is necessary, the API should respond clearly with 501 Not Implemented instead of pretending the operation failed unexpectedly. Clients can integrate against the contract while teams complete the implementation.
Backward-compatible design protects existing consumers. Published behavior must keep working. Existing fields should not change type. Optional fields should not become required. Routes should not be removed without a migration path. The safest changes are additive, such as adding a new optional field or a new endpoint.
Versioning should be reserved for external contract changes, not internal refactoring. Changing a database, reorganizing packages, or replacing the implementation language does not require a new API version if clients observe the same behavior.
Common versioning strategies include:
| Strategy | Example | Strength | Risk |
|---|---|---|---|
| URI path versioning | /v1/dispatches |
Clear, easy to route, simple to test | Resource identity includes the version |
| Query parameter versioning | /dispatches?api-version=2 |
Stable base path | Can be less visible and awkward with caches |
| Header versioning | X-API-Version: 2 |
Clean URLs, useful in controlled environments | Harder to debug manually |
| Media type versioning | application/dispatch.v2+json |
Precise contract negotiation | Operationally complex for many teams |
Path versioning is often the easiest for teams to understand and operate. More advanced strategies can work, but they require stronger governance, better tooling, and disciplined consumers.
Treating Errors as Contracted Responses
Errors are not private implementation details. They tell clients whether to retry, show a validation message, ask the user to change input, or stop the workflow.
A good error design separates two concerns:
- The HTTP status code gives the high-level result.
- The structured body explains the specific problem.
For example, 409 Conflict can indicate that the request is valid but conflicts with current state. 422 Unprocessable Entity can indicate that the request shape is valid but the content cannot be accepted. Returning 500 for client input mistakes can trigger pointless retries. Returning 400 for temporary server failures can prevent useful retry behavior.
A small Problem Details style response type can be represented in Go like this:
type Problem struct {
Type string `json:"type"`
Title string `json:"title"`
Detail string `json:"detail,omitempty"`
Trace *string `json:"traceId,omitempty"`
}
func writeProblem(c *gin.Context, status int, code string, title string, detail string) {
problem := Problem{
Type: "problem." + code,
Title: title,
Detail: detail,
}
if traceID := c.GetHeader("X-Trace-ID"); traceID != "" {
problem.Trace = &traceID
}
c.JSON(status, problem)
}
The Type value is important because clients can branch on it. The title should stay stable for a class of error. The detail can explain the specific failure. A trace or correlation identifier helps clients and operators connect one failed request to logs and traces.
Bulk operations need one more decision. If a request processes many items, failure may be partial. A mature API can return 207 Multi-Status and include per-item results so clients know exactly which items succeeded and which failed.
A Practical Workflow for Building the API
A disciplined API-First workflow can be simple:
- Model resources as nouns and decide which operations clients actually need.
- Write the OpenAPI contract with request schemas, response schemas, status codes, and error responses.
- Review the contract with consumers before implementation becomes expensive.
- Generate Go models and server interfaces from the contract.
- Keep generated DTOs inside the HTTP adapter.
- Convert DTOs into domain models before calling service ports.
- Implement handlers that delegate business decisions to the domain service.
- Wire the generated routes into the HTTP server.
- Add tests that protect field names, required fields, status codes, and error bodies.
- Treat every contract change as a compatibility decision.
Useful commands during this workflow are straightforward:
go generate ./api
go test ./...
Generation keeps the server interface aligned with the contract. Tests protect the behavior behind that contract.
Common Mistakes
Letting generated models become domain models
Generated DTOs are shaped by transport needs. Domain models are shaped by business needs. Mixing them creates accidental coupling and makes internal refactoring risky.
Adding fields too early
A field added without a real client need is still a promise. Keep the API surface small until demand is clear.
Hiding missing versus false with omitempty
For booleans and numbers, omitempty can remove values that clients may need to see. Use pointer fields when absence and an explicit zero value mean different things.
Treating status codes casually
Status codes influence retries, monitoring, alerts, and user feedback. Choose them deliberately. Invalid content and server failures should not look the same.
Versioning for internal changes
A new API version is for client-visible contract changes. Internal rewrites should preserve the external API when possible.
Returning plain text errors
Plain strings are hard for clients to parse and hard for systems to observe. Use structured errors with stable types and trace information.
Checklist
Before publishing a REST API contract, confirm the following:
- The API resources are named as business nouns.
- Each route has a clear purpose and client need.
- The OpenAPI file is stored in version control.
- Generated code lives in the HTTP adapter, not in the domain.
- Converters exist between generated DTOs and domain models.
- Optional fields are modeled deliberately.
- Struct tags are reviewed as public API details.
- Error responses are structured and documented.
- Status codes separate client mistakes from server problems.
- New fields are additive and optional when possible.
- Versioning is used only when the external contract changes.
- Health and operational endpoints are clearly separated from business endpoints.
Conclusion
API-First design turns a REST API from an implementation side effect into a shared engineering contract. OpenAPI gives that contract a form that humans can review and tools can execute. In Go, generated models and server interfaces reduce repetitive work, while hexagonal boundaries keep the domain independent from HTTP concerns.
The main discipline is separation. Let the contract describe the outside world. Let handlers adapt that contract to ports. Let domain services make business decisions. Let converters protect the boundary between generated DTOs and internal models.
A well-designed API gives clients confidence and gives the service team freedom. Clients see stable resources, predictable responses, and clear errors. Inside the service, developers can refactor, optimize, replace infrastructure, and improve code without breaking the promise made at the boundary.