GoAPI Design
June 13, 2026

Building Contract-First REST APIs in Go with OpenAPI and Clean Boundaries

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:

  • api holds the OpenAPI file and generator configuration.
  • internal/adapters/http/api holds generated models and server interfaces.
  • internal/adapters/http/converter maps generated DTOs to domain models.
  • internal/adapters/http/handler implements the generated server interface.
  • internal/models contains domain structures with no HTTP dependency.
  • internal/ports defines 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 VehicleCode in Go does not have to rename vehicleCode in the API.
  • omitempty removes 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:

  1. Model resources as nouns and decide which operations clients actually need.
  2. Write the OpenAPI contract with request schemas, response schemas, status codes, and error responses.
  3. Review the contract with consumers before implementation becomes expensive.
  4. Generate Go models and server interfaces from the contract.
  5. Keep generated DTOs inside the HTTP adapter.
  6. Convert DTOs into domain models before calling service ports.
  7. Implement handlers that delegate business decisions to the domain service.
  8. Wire the generated routes into the HTTP server.
  9. Add tests that protect field names, required fields, status codes, and error bodies.
  10. 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.

Share:

Comments0

Home Profile Menu Sidebar
Top