Let's cut to the chase. You need to build a REST API in Go. You want it to be modern, maintainable, and built following best practices: this is a hands-on guide to doing it the right way.
The Problem: Specification Hell or Heaven?
So, you're starting a greenfield project. The eternal question arises: do you write the code first and then generate the API specification (like OpenAPI/Swagger)? Or do you write the spec first and then generate the code?
This isn't just a philosophical debate. It has real-world consequences:
- Code-First: You move fast initially, but the spec often becomes an afterthought. It drifts from the actual implementation, leading to confused frontend teams, incorrect documentation, and integration nightmares.
- Spec-First: This seems slower upfront, but it forces you to think through your API design. The spec becomes the source of truth.
Our Approach: We're going all-in on spec-first. Why? Because it unlocks massive benefits. By creating an openapi.yaml
file first, we establish a contract. Other teams (frontend, QA, other backend services) can immediately use this contract to generate mock servers and start their work. They aren't blocked by our implementation. We are all working in parallel, protected by a clear, agreed-upon interface.
This also sets us up to leverage powerful code generators. We can automatically create server stubs, data models, and even client-side code, all from that single YAML file. This isn't just efficient; it's a key to long-term maintainability.
The Plan: Spec -> Generate -> Implement
Here’s how we're going to tackle this:
- Define the API Contract: We'll write an
openapi.yaml
file. This is our single source of truth. - Structure the Project: We'll lay out our Go project in a clean, idiomatic way.
- Generate Code: Using
oapi-codegen
, we'll generate the server scaffolding and data types directly from our YAML file. - Implement Business Logic: We'll write the actual logic for our API endpoints.
- Automate with Make: We'll use a
Makefile
to streamline the code generation process, making it a repeatable, one-command step.
Project Structure: Go Standard Layout
A good project structure is crucial for maintainability. We'll follow a layout that is common in the Go community and promotes a clean separation of concerns.
/my-api
├── cmd/
│ └── api/
│ └── main.go # Entry point of our application
├── docs/
│ └── openapi.yaml # Our API specification (source of truth)
├── internal/
│ ├── api/
│ │ ├── openapi_server.gen.go # Generated server code
│ │ └── openapi_types.gen.go # Generated type definitions
│ └── handler/
│ └── handler.go # Our business logic implementation
├── go.mod
├── go.sum
└── Makefile
-
cmd/api/main.go
: This is where our application starts. Its job is to initialize the database connections, set up the Gin router, and wire everything together. -
docs/openapi.yaml
: The heart of our spec-first approach. -
internal/
: This is a special directory in Go. Code insideinternal
can only be imported by code within the same project, preventing other projects from accidentally depending on our internal logic.-
api/
: This package will hold the code generated byoapi-codegen
. We keep it separate to make it clear what is machine-generated vs. hand-written. -
handler/
: This is where we'll write the actual implementation for our API endpoints (the "business logic").
-
-
Makefile
: A simple script to automate our development tasks, like code generation.
Step 1: Define the API with OpenAPI 3
First, let's create our API specification in docs/openapi.yaml
. We'll define two simple endpoints: one to get a list of users and another to get a list of products.
# docs/openapi.yaml
openapi: 3.0.3
info:
title: "Simple API"
version: "1.0.0"
paths:
/users:
get:
summary: "Get a list of users"
operationId: "getUsers"
responses:
'200':
description: "A list of users"
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/User'
/products:
get:
summary: "Get a list of products"
operationId: "getProducts"
responses:
'200':
description: "A list of products"
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/Product'
components:
schemas:
User:
type: object
properties:
id:
type: integer
format: int64
name:
type: string
Product:
type: object
properties:
sku:
type: string
name:
type: string
price:
type: number
format: float
Step 2: Automate Code Generation with a Makefile
Now for the magic. We'll use oapi-codegen
to read our YAML file and generate Go code. With Go 1.24+, we can manage tools like this directly in our go.mod
file, which is fantastic for ensuring consistent builds.
First, add the tool to your project:
go get -u github.com/gin-gonic/gin
go get -tool github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen@latest
This command does two things:
- Adds
gin
as a project dependency. - Adds
oapi-codegen
as a tracked tool ingo.mod
.
Now, create the Makefile
:
# Makefile
.PHONY: gen-code
gen-code:
@echo "--- Generating OpenAPI server and types ---"
@go tool oapi-codegen --config=codegen.yaml docs/openapi.yaml
.PHONY: tidy
tidy:
@go mod tidy
For more complex projects, using a configuration file for oapi-codegen
is cleaner. Create codegen.yaml
:
# codegen.yaml
package: api
generate:
- types
- gin
output: internal/api/openapi.gen.go
This configuration tells the tool to generate the types
and gin
server code into a single file. Now, running make gen-code
will generate our server interface and all the required structs into internal/api/openapi.gen.go
.
Step 3: Implement the Business Logic
The generated code provides us with a ServerInterface
. Our job is to create a struct that implements this interface. This is where our actual business logic lives.
Create the file internal/handler/handler.go
:
// internal/handler/handler.go
package handler
import (
"net/http"
"my-api/internal/api" // Import the generated package
"github.com/gin-gonic/gin"
)
// Server implements the generated ServerInterface.
type Server struct{}
// Make sure we conform to the interface
var _ api.ServerInterface = (*Server)(nil)
// getUsers implements the business logic for the /users endpoint.
func (s *Server) GetUsers(c *gin.Context) {
users := []api.User{
{Id: 1, Name: "Alice"},
{Id: 2, Name: "Bob"},
}
c.JSON(http.StatusOK, users)
}
// getProducts implements the business logic for the /products endpoint.
func (s *Server) GetProducts(c *gin.Context) {
products := []api.Product{
{Sku: "A123", Name: "Laptop", Price: 1200.50},
{Sku: "B456", Name: "Mouse", Price: 25.00},
}
c.JSON(http.StatusOK, products)
}
Notice how we're using the api.User
and api.Product
structs that were generated for us. If we change the spec and regenerate, the compiler will tell us exactly what we need to update in our handler. This is a remarkable win for maintainability.
Step 4: Wire It All Together
Finally, let's write our main.go
to start the server.
// cmd/api/main.go
package main
import (
"my-api/internal/api"
"my-api/internal/handler"
"github.com/gin-gonic/gin"
)
func main() {
// Create our handler which satisfies the generated interface
myHandler := &handler.Server{}
// Set up the Gin router
r := gin.Default()
// Use the generated RegisterHandlers function to wire up our handlers
// to the Gin router.
api.RegisterHandlers(r, myHandler)
// Start the server
r.Run(":8080")
}
The api.RegisterHandlers
function is another powerful piece of the generated code. It takes care of all the boilerplate for routing requests to the correct handler methods.
Conclusion: A Maintainable, Scalable Foundation
And that's it! You now have a fully functional, spec-driven REST API.
Let's recap the benefits of this approach:
-
Single Source of Truth:
openapi.yaml
is the contract. All code is generated or validated against it. - Parallel Development: Frontend and backend teams can work simultaneously, unlocked by the API spec.
- Type Safety: Go's strong type system, combined with code generation, catches errors at compile time, not runtime.
-
Reduced Boilerplate:
oapi-codegen
handles the tedious work of routing and data model creation. -
Consistency: The
Makefile
andgo tool
support ensure that every developer on your team generates code the exact same way.
This spec-first methodology provides a robust foundation for building APIs in Go that are not only fast and efficient but also a pleasure to maintain and scale over time. Happy coding!
Top comments (0)