Why Go?
Go (often called Golang) was designed by Google engineers in 2007 and released publicly in 2009. It was built to solve real frustrations engineers were having with existing languages at scale — slow compile times in C++, complex concurrency models, and verbose dependency management.
What came out of that frustration is a language that feels almost disarmingly simple on the surface, yet powers some of the most demanding production systems in the world: Docker, Kubernetes, Terraform, Prometheus, and CockroachDB are all written in Go.
Here is why it deserves your attention regardless of what language you already know:
- Fast compile times. Go compiles to a single, self-contained native binary in seconds.
- Garbage collected, but low overhead. You get memory management without the JVM startup weight.
- Goroutines make concurrency accessible. Spawning 10,000 concurrent tasks in Go is routine, not rocket science.
- Minimal language surface. There are 25 keywords in the entire language. You will not spend weeks memorizing syntax.
- One way to do things. The Go toolchain enforces a standard format (
gofmt), opinionated error handling, and a standard project layout. This dramatically improves cross-team readability.
If you are coming from Python, Go will feel strict but rewarding. If you are coming from Node.js, the performance headroom and static typing will immediately stand out. If you are coming from PHP or Java, Go’s concurrency model will feel like a superpower.
By the end of this guide, you will have built a production-ready CRUD API with contract-first design using OpenAPI 3.0, auto-generated server stubs via oapi-codegen, and interactive docs rendered by Scalar.
Prerequisites
- Go 1.21+ installed (go.dev/dl)
- Basic familiarity with any programming language (variables, functions, loops)
- A terminal and your preferred editor (VS Code with the
golang.goextension is recommended)
Verify your installation:
go version
# go version go1.22.5 linux/amd64
Part 1 — The Go Mental Model
Before writing any code, three ideas separate Go from most languages you have used:
1. Everything is explicit
Go does not hide complexity behind magic. There are no annotations, decorators, or convention-over-configuration patterns. If an error can happen, you handle it explicitly. If a variable might not exist, you return a second value or use a pointer. You will write slightly more code, and in exchange, every line is traceable.
2. Errors are values, not exceptions
In most languages, something going wrong throws an exception and interrupts the flow of the program. In Go, a function that can fail returns two values: the result and an error. You check the error, decide what to do, and continue. This makes error paths first-class citizens of your code, not afterthoughts.
3. Concurrency via goroutines and channels
Go’s concurrency model is built into the language with goroutines (lightweight threads) and channels (typed pipes for sending data between goroutines). You will not need this for your first CRUD API, but it is the reason Go handles thousands of concurrent connections with ease.
Part 2 — Go Syntax from Zero
Setting Up a Module
Every Go project starts with a module. A module is Go’s unit of dependency management — equivalent to package.json in Node.js or pyproject.toml in Python.
mkdir go-bookstore && cd go-bookstore
go mod init github.com/yourusername/go-bookstore
This creates a go.mod file:
module github.com/yourusername/go-bookstore
go 1.22
The main Package
Every Go program has exactly one entry point: the main function inside the main package.
// main.go
package main
import "fmt"
func main() {
fmt.Println("Hello, Go.")
}
Run it:
go run main.go
Three things to notice immediately:
package maindeclares which package this file belongs to.import "fmt"brings in Go’s formatting and printing package.- Capitalized identifiers from packages are exported (public). Lowercase are unexported (private to the package).
Variables and Types
Go is statically typed. Every variable has a type, either declared explicitly or inferred from the value.
// Explicit declaration
var name string = "Nathan"
var age int = 23
// Short variable declaration (inside functions only)
city := "Manila"
isStudent := true
// Zero values — Go always initializes variables to their zero value
var count int // 0
var label string // ""
var active bool // false
var ratio float64 // 0.0
Go’s built-in primitive types:
| Type | Description | Example |
|---|---|---|
int, int32, int64 |
Integer | 42 |
float32, float64 |
Floating point | 3.14 |
string |
UTF-8 string | "Go" |
bool |
Boolean | true, false |
byte |
Alias for uint8 |
'G' |
rune |
Alias for int32, a Unicode code point |
'な' |
Functions
Go functions can return multiple values — the core mechanism for idiomatic error handling.
// Basic function
func greet(name string) string {
return "Hello, " + name
}
// Multiple return values
func divide(a, b float64) (float64, error) {
if b == 0 {
return 0, fmt.Errorf("cannot divide by zero")
}
return a / b, nil
}
// Named return values (with defensive bounds check)
func minMax(numbers []int) (min, max int) {
if len(numbers) == 0 {
return 0, 0
}
min, max = numbers[0], numbers[0]
for _, n := range numbers {
if n < min { min = n }
if n > max { max = n }
}
return // "naked return" returns named values
}
Calling a function that returns an error — this is the Go way:
result, err := divide(10, 0)
if err != nil {
fmt.Println("Error:", err)
return
}
fmt.Println("Result:", result)
You will write if err != nil hundreds of times in Go. This is intentional. Every error path is visible and handled.
Control Flow
// if / else — no parentheses required
if x > 10 {
fmt.Println("big")
} else if x > 5 {
fmt.Println("medium")
} else {
fmt.Println("small")
}
// if with initializer — commonly used with error handling
if val, err := someFunc(); err != nil {
fmt.Println("failed:", err)
} else {
fmt.Println("got:", val)
}
// for — Go has only one loop construct
for i := 0; i < 5; i++ {
fmt.Println(i)
}
// for as while
i := 0
for i < 5 {
i++
}
// range — iterate over slices, maps, strings, channels
fruits := []string{"apple", "mango", "banana"}
for index, value := range fruits {
fmt.Printf("%d: %s\n", index, value)
}
// Discard the index with _
for _, value := range fruits {
fmt.Println(value)
}
// switch — no fallthrough by default
day := "Saturday"
switch day {
case "Saturday", "Sunday":
fmt.Println("Weekend")
case "Friday":
fmt.Println("Almost there")
default:
fmt.Println("Weekday")
}
Data Structures
Slices — the dynamic collection you will use everywhere.
books := []string{"Dune", "Foundation", "Neuromancer"}
// Append
books = append(books, "Project Hail Mary")
// Slice a slice
firstTwo := books[0:2] // ["Dune", "Foundation"]
// Make with length and capacity
ids := make([]int, 0, 10)
fmt.Println(len(books), cap(books))
Maps — key-value store.
inventory := map[string]int{
"apples": 10,
"mangoes": 5,
}
// Check if key exists — the idiomatic two-value assignment
count, ok := inventory["durian"]
if !ok {
fmt.Println("durian not found")
}
inventory["lanzones"] = 8
delete(inventory, "apples")
for item, qty := range inventory {
fmt.Printf("%s: %d\n", item, qty)
}
Structs — Go’s Primary Data Container
Go does not have classes. It has structs — composite types that group fields together.
type Book struct {
ID string
Title string
Author string
Price float64
InStock bool
}
b := Book{
ID: "1",
Title: "The Go Programming Language",
Author: "Alan Donovan",
Price: 45.00,
InStock: true,
}
fmt.Println(b.Title)
// Pointer to struct — avoids copying the entire struct
bp := &Book{Title: "Concurrency in Go"}
bp.Price = 38.00 // Go auto-dereferences — same as (*bp).Price = 38.00
Methods on Structs
type Book struct {
Title string
Author string
Price float64
}
// Value receiver — does not modify the original
func (b Book) Summary() string {
return fmt.Sprintf("'%s' by %s — $%.2f", b.Title, b.Author, b.Price)
}
// Pointer receiver — modifies the original
func (b *Book) ApplyDiscount(percent float64) {
b.Price = b.Price * (1 - percent/100)
}
book := Book{Title: "The Go Programming Language", Author: "Alan Donovan", Price: 45.00}
fmt.Println(book.Summary())
book.ApplyDiscount(10)
fmt.Println(book.Price) // 40.50
Rule of thumb: use pointer receivers when the method needs to modify the struct or when the struct is large.
Interfaces
Interfaces are implicit in Go — any type that implements all the methods of an interface automatically satisfies it. You do not declare implements.
type Pricer interface {
Price() float64
}
type Book struct {
Title string
Cost float64
}
func (b Book) Price() float64 {
return b.Cost
}
type DigitalBook struct {
Title string
Cost float64
FileSizeMB int
}
func (d DigitalBook) Price() float64 {
return d.Cost * 0.8 // digital discount
}
// This function accepts anything that satisfies Pricer
func PrintPrice(p Pricer) {
fmt.Printf("Price: $%.2f\n", p.Price())
}
PrintPrice(Book{Title: "Dune", Cost: 25.00})
PrintPrice(DigitalBook{Title: "Foundation", Cost: 20.00})
Error Handling — The Complete Pattern
// Define a custom error type
type NotFoundError struct {
Resource string
ID string
}
func (e *NotFoundError) Error() string {
return fmt.Sprintf("%s with ID %s not found", e.Resource, e.ID)
}
func GetBook(id string, store map[string]Book) (Book, error) {
book, ok := store[id]
if !ok {
return Book{}, &NotFoundError{Resource: "Book", ID: id}
}
return book, nil
}
// Check the type of error with errors.As
book, err := GetBook("999", store)
if err != nil {
var notFound *NotFoundError
if errors.As(err, ¬Found) {
fmt.Println("Resource missing:", notFound.Resource)
} else {
fmt.Println("Unexpected error:", err)
}
}
Part 3 — Building a CRUD API
We will build a bookstore API. The spec first, the implementation second — that is the contract-first approach.
The Architecture
┌────────────────────────────────────────────┐
│ go-bookstore │
│ │
│ ┌──────────┐ ┌──────────┐ ┌────────┐ │
│ │OpenAPI │──▶│oapi-codeG│─▶│Handler │ │
│ │Spec YAML │ │(gen tool)│ │ Code │ │
│ └──────────┘ └──────────┘ └────────┘ │
│ │ │
│ ┌──────────┐ ┌──────────┐ │ │
│ │ Scalar │ │In-memory │◀─────┘ │
│ │ API Docs│ │ Store │ │
│ └──────────┘ └──────────┘ │
└────────────────────────────────────────────┘
Stack:
net/http— Go’s standard library HTTP serveroapi-codegen— generates Go server interfaces and types from your OpenAPI specScalar— renders interactive API documentation from your spec
Project Structure
go-bookstore/
api/
openapi.yaml # OpenAPI 3.0 contract
spec.go # Embeds openapi.yaml for clean cross-package access
cmd/
main.go
internal/
handler/
books.go
store/
memory.go
api/
gen.go # oapi-codegen output — never edit manually
go.mod
go.sum
oapi-codegen.yaml
This layout follows Go’s idiomatic project structure:
cmd/— binary entry pointsinternal/— packages private to this module (Go enforces this)api/— API specification files
Step 1: Write the OpenAPI Spec
The spec is your contract. Write it first.
mkdir -p api cmd internal/handler internal/store internal/api
api/openapi.yaml:
openapi: "3.0.3"
info:
title: "Bookstore API"
version: "1.0.0"
description: "A simple CRUD API for managing books."
servers:
- url: "http://localhost:8080"
paths:
/books:
get:
summary: "List all books"
operationId: "ListBooks"
tags: ["Books"]
responses:
"200":
description: "A list of books"
content:
application/json:
schema:
type: array
items:
$ref: "#/components/schemas/Book"
post:
summary: "Create a new book"
operationId: "CreateBook"
tags: ["Books"]
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/CreateBookRequest"
responses:
"201":
description: "Book created"
content:
application/json:
schema:
$ref: "#/components/schemas/Book"
"400":
description: "Invalid request body"
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
/books/{id}:
parameters:
- name: id
in: path
required: true
schema:
type: string
get:
summary: "Get a book by ID"
operationId: "GetBook"
tags: ["Books"]
responses:
"200":
description: "The requested book"
content:
application/json:
schema:
$ref: "#/components/schemas/Book"
"404":
description: "Book not found"
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
put:
summary: "Update a book"
operationId: "UpdateBook"
tags: ["Books"]
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/CreateBookRequest"
responses:
"200":
description: "Book updated"
content:
application/json:
schema:
$ref: "#/components/schemas/Book"
"400":
description: "Invalid request body"
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
"404":
description: "Book not found"
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
delete:
summary: "Delete a book"
operationId: "DeleteBook"
tags: ["Books"]
responses:
"204":
description: "Book deleted successfully"
"404":
description: "Book not found"
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
components:
schemas:
Book:
type: object
required: [id, title, author, price]
properties:
id:
type: string
example: "1729123456789"
title:
type: string
example: "The Go Programming Language"
author:
type: string
example: "Alan Donovan"
price:
type: number
format: float
example: 45.00
in_stock:
type: boolean
example: true
CreateBookRequest:
type: object
required: [title, author, price]
properties:
title:
type: string
example: "Concurrency in Go"
author:
type: string
example: "Katherine Cox-Buday"
price:
type: number
format: float
example: 38.00
in_stock:
type: boolean
default: true
ErrorResponse:
type: object
required: [message]
properties:
message:
type: string
example: "book with ID xyz not found"
Next, create api/spec.go. Because Go’s //go:embed directive strictly forbids traversing parent directories with .., embedding the file directly within the api package gives the entire application clean, type-safe access to the raw OpenAPI specification:
// api/spec.go
package api
import _ "embed"
// OpenAPISpec embeds the raw OpenAPI YAML specification into the compiled binary.
//
//go:embed openapi.yaml
var OpenAPISpec []byte
Step 2: Install and Configure oapi-codegen
go install github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen@latest
Create oapi-codegen.yaml:
package: api
generate:
models: true
std-http-server: true
strict-server: true
output: internal/api/gen.go
Generate:
oapi-codegen --config oapi-codegen.yaml api/openapi.yaml
This creates internal/api/gen.go — never edit this file manually. Regenerate it whenever the spec changes.
What gets generated:
Book,CreateBookRequest,ErrorResponse— Go structs matching your schemasStrictServerInterface— the interface your handler must implementHandlerFromMux(...)— wires the interface tonet/http
Step 3: The In-Memory Store
internal/store/memory.go:
package store
import (
"fmt"
"sort"
"sync"
"sync/atomic"
)
type Book struct {
ID string
Title string
Author string
Price float64
InStock bool
}
// idCounter provides monotonic, race-free IDs without timestamp collisions in tight loops.
var idCounter atomic.Int64
func generateID() string {
return fmt.Sprintf("%d", idCounter.Add(1))
}
// Store is thread-safe — concurrent HTTP requests require this
type Store struct {
mu sync.RWMutex
books map[string]Book
}
func NewStore() *Store {
s := &Store{books: make(map[string]Book)}
seeds := []Book{
{Title: "The Go Programming Language", Author: "Alan Donovan", Price: 45.00, InStock: true},
{Title: "Concurrency in Go", Author: "Katherine Cox-Buday", Price: 38.00, InStock: true},
{Title: "Let's Go", Author: "Alex Edwards", Price: 30.00, InStock: false},
}
for _, b := range seeds {
b.ID = generateID()
s.books[b.ID] = b
}
return s
}
func (s *Store) List() []Book {
s.mu.RLock()
defer s.mu.RUnlock()
books := make([]Book, 0, len(s.books))
for _, b := range s.books {
books = append(books, b)
}
// Go maps randomize key iteration order by design.
// Sorting by ID ensures deterministic, predictable API responses.
sort.Slice(books, func(i, j int) bool {
return books[i].ID < books[j].ID
})
return books
}
func (s *Store) Get(id string) (Book, error) {
s.mu.RLock()
defer s.mu.RUnlock()
b, ok := s.books[id]
if !ok {
return Book{}, fmt.Errorf("book with ID %s not found", id)
}
return b, nil
}
func (s *Store) Create(title, author string, price float64, inStock bool) Book {
s.mu.Lock()
defer s.mu.Unlock()
b := Book{ID: generateID(), Title: title, Author: author, Price: price, InStock: inStock}
s.books[b.ID] = b
return b
}
func (s *Store) Update(id, title, author string, price float64, inStock bool) (Book, error) {
s.mu.Lock()
defer s.mu.Unlock()
if _, ok := s.books[id]; !ok {
return Book{}, fmt.Errorf("book with ID %s not found", id)
}
updated := Book{ID: id, Title: title, Author: author, Price: price, InStock: inStock}
s.books[id] = updated
return updated, nil
}
func (s *Store) Delete(id string) error {
s.mu.Lock()
defer s.mu.Unlock()
if _, ok := s.books[id]; !ok {
return fmt.Errorf("book with ID %s not found", id)
}
delete(s.books, id)
return nil
}
Why
sync.RWMutex? In-memory maps in Go are not safe for concurrent reads and writes.RWMutexallows multiple simultaneous readers (RLock) but only one writer at a time (Lock). This is essential for any server handling concurrent requests — and Go’s HTTP server runs each request in its own goroutine by default.
Step 4: The Handler
internal/handler/books.go:
package handler
import (
"context"
api "github.com/yourusername/go-bookstore/internal/api"
"github.com/yourusername/go-bookstore/internal/store"
)
// Compile-time interface check.
// This line causes a build error if BooksHandler doesn't implement StrictServerInterface.
var _ api.StrictServerInterface = (*BooksHandler)(nil)
type BooksHandler struct {
store *store.Store
}
func NewBooksHandler(s *store.Store) *BooksHandler {
return &BooksHandler{store: s}
}
// toAPIBook maps the internal domain model to the API response type.
// This layer prevents internal changes from leaking into the API contract.
func toAPIBook(b store.Book) api.Book {
inStock := b.InStock
return api.Book{
Id: b.ID,
Title: b.Title,
Author: b.Author,
Price: float32(b.Price),
InStock: &inStock,
}
}
func (h *BooksHandler) ListBooks(
ctx context.Context,
request api.ListBooksRequestObject,
) (api.ListBooksResponseObject, error) {
books := h.store.List()
resp := make([]api.Book, len(books))
for i, b := range books {
resp[i] = toAPIBook(b)
}
return api.ListBooks200JSONResponse(resp), nil
}
func (h *BooksHandler) CreateBook(
ctx context.Context,
request api.CreateBookRequestObject,
) (api.CreateBookResponseObject, error) {
if request.Body == nil || request.Body.Title == "" || request.Body.Author == "" {
return api.CreateBook400JSONResponse{Message: "title and author are required"}, nil
}
body := request.Body
inStock := true
if body.InStock != nil {
inStock = *body.InStock
}
created := h.store.Create(body.Title, body.Author, float64(body.Price), inStock)
return api.CreateBook201JSONResponse(toAPIBook(created)), nil
}
func (h *BooksHandler) GetBook(
ctx context.Context,
request api.GetBookRequestObject,
) (api.GetBookResponseObject, error) {
book, err := h.store.Get(request.Id)
if err != nil {
return api.GetBook404JSONResponse{Message: err.Error()}, nil
}
return api.GetBook200JSONResponse(toAPIBook(book)), nil
}
func (h *BooksHandler) UpdateBook(
ctx context.Context,
request api.UpdateBookRequestObject,
) (api.UpdateBookResponseObject, error) {
if request.Body == nil || request.Body.Title == "" || request.Body.Author == "" {
return api.UpdateBook400JSONResponse{Message: "title and author are required"}, nil
}
body := request.Body
inStock := true
if body.InStock != nil {
inStock = *body.InStock
}
updated, err := h.store.Update(request.Id, body.Title, body.Author, float64(body.Price), inStock)
if err != nil {
return api.UpdateBook404JSONResponse{Message: err.Error()}, nil
}
return api.UpdateBook200JSONResponse(toAPIBook(updated)), nil
}
func (h *BooksHandler) DeleteBook(
ctx context.Context,
request api.DeleteBookRequestObject,
) (api.DeleteBookResponseObject, error) {
if err := h.store.Delete(request.Id); err != nil {
return api.DeleteBook404JSONResponse{Message: err.Error()}, nil
}
return api.DeleteBook204Response{}, nil
}
Notice what is absent: no JSON marshaling, no route matching, no HTTP status strings. oapi-codegen handles all of that. Your handler is pure Go business logic.
Step 5: Wire Everything in main.go
cmd/main.go:
package main
import (
"log"
"net/http"
apiSpec "github.com/yourusername/go-bookstore/api"
gen "github.com/yourusername/go-bookstore/internal/api"
"github.com/yourusername/go-bookstore/internal/handler"
"github.com/yourusername/go-bookstore/internal/store"
)
func main() {
bookStore := store.NewStore()
booksHandler := handler.NewBooksHandler(bookStore)
strictHandler := gen.NewStrictHandler(booksHandler, nil)
mux := http.NewServeMux()
gen.HandlerFromMux(strictHandler, mux)
// Serve the raw spec — Scalar reads it from here
mux.HandleFunc("GET /openapi.yaml", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/yaml")
w.Write(apiSpec.OpenAPISpec)
})
// Scalar renders beautiful, interactive API docs from your spec
mux.HandleFunc("GET /docs", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html")
w.Write([]byte(`<!doctype html>
<html>
<head>
<title>Bookstore API Docs</title>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
</head>
<body>
<script
id="api-reference"
data-url="/openapi.yaml"
src="https://cdn.jsdelivr.net/npm/@scalar/api-reference"></script>
</body>
</html>`))
})
addr := ":8080"
log.Printf("Bookstore API → http://localhost%s/books", addr)
log.Printf("Interactive Docs → http://localhost%s/docs", addr)
if err := http.ListenAndServe(addr, mux); err != nil {
log.Fatal("server error:", err)
}
}
//go:embed& Package Boundaries: In Go,//go:embeddirectives cannot traverse parent directories with relative paths (..). By declaringvar OpenAPISpec []byteinsideapi/spec.gowhereopenapi.yamlresides, the raw specification is embedded directly into the binary at compile time and cleanly imported wherever needed.
Step 6: Run It
go mod tidy
go run ./cmd/main.go
2026/10/17 12:00:00 Bookstore API → http://localhost:8080/books
2026/10/17 12:00:00 Interactive Docs → http://localhost:8080/docs
Testing the API
# List all books
curl http://localhost:8080/books | jq .
# Create a book
curl -X POST http://localhost:8080/books \
-H "Content-Type: application/json" \
-d '{"title": "Learning Go", "author": "Jon Bodner", "price": 42.99, "in_stock": true}' | jq .
# Get a book by ID (replace <id> with one from the list)
curl http://localhost:8080/books/<id> | jq .
# Update a book
curl -X PUT http://localhost:8080/books/<id> \
-H "Content-Type: application/json" \
-d '{"title": "Learning Go, 2nd Ed.", "author": "Jon Bodner", "price": 47.99, "in_stock": true}' | jq .
# Delete a book
curl -X DELETE http://localhost:8080/books/<id>
# Returns 204 No Content on success
Open http://localhost:8080/docs in your browser. Scalar renders your entire API as an interactive reference — try each endpoint directly from the docs page.
Part 4 — Testing in Go
Go’s standard library ships a first-class testing package. No external runner required.
// internal/store/memory_test.go
package store_test
import (
"testing"
"github.com/yourusername/go-bookstore/internal/store"
)
func TestStore_Create(t *testing.T) {
s := store.NewStore()
book := s.Create("Test Book", "Test Author", 20.00, true)
if book.ID == "" {
t.Error("expected non-empty ID")
}
if book.Title != "Test Book" {
t.Errorf("expected title 'Test Book', got '%s'", book.Title)
}
}
func TestStore_Get_NotFound(t *testing.T) {
s := store.NewStore()
_, err := s.Get("non-existent-id")
if err == nil {
t.Error("expected an error for non-existent ID, got nil")
}
}
func TestStore_Delete(t *testing.T) {
s := store.NewStore()
book := s.Create("Delete Me", "Author", 10.00, true)
if err := s.Delete(book.ID); err != nil {
t.Fatalf("unexpected error deleting book: %v", err)
}
_, err := s.Get(book.ID)
if err == nil {
t.Error("expected book to be gone after deletion")
}
}
Run tests:
go test ./... # all tests
go test ./... -v # verbose output
go test ./... -cover # coverage report
go test ./... -race # data race detector — always use this in CI
Part 5 — Go Ecosystem: Frameworks to Explore Next
net/http plus oapi-codegen is the idiomatic, zero-dependency approach for production APIs in Go. Once comfortable, here is where the ecosystem extends it:
HTTP Routers and Frameworks
chi — the most popular lightweight router for net/http.
- Adds named URL parameters, middleware chaining, and route grouping.
- Works natively with
oapi-codegen(chi-server: truein the config).
go get github.com/go-chi/chi/v5
Gin — the most downloaded Go web framework.
- Fastest raw HTTP performance among the popular frameworks.
- Built-in JSON binding, validation middleware, route grouping.
go get github.com/gin-gonic/gin
Echo — high-performance minimalist framework with clean middleware API and first-class OpenAPI support.
go get github.com/labstack/echo/v4
Fiber — Express.js-inspired, built on fasthttp.
- Familiar API for Node.js/Express developers.
- Trade-off: incompatible with the
net/httpmiddleware ecosystem.
go get github.com/gofiber/fiber/v2
Database
pgx — the best PostgreSQL driver for Go. Recommended over database/sql for new projects.
sqlc — generates type-safe Go code from raw SQL queries. Pairs excellently with oapi-codegen for a fully contract-first, type-safe stack from spec to database.
GORM — full-featured ORM for rapid development. Use raw SQL or sqlc for performance-critical queries.
What You Built and What It Demonstrates
| Concept | Where It Appears |
|---|---|
| Static typing and zero values | Every struct and function signature |
| Multiple return values | store.Get() returning (Book, error) |
if err != nil pattern |
Every store method call in the handler |
| Implicit interfaces | StrictServerInterface satisfied by BooksHandler |
| Pointer receivers | store.Update(), store.Delete() mutating state |
sync.RWMutex |
Thread-safe concurrent map access |
| Contract-first API design | openapi.yaml as the single source of truth |
| Code generation | oapi-codegen producing types and interfaces from the spec |
//go:embed |
Bundling openapi.yaml into the compiled binary |
| Package boundaries | internal/ enforcing private package scope |
| Interactive API docs | Scalar rendering from /openapi.yaml |
Key Takeaways
- Go is verbose where it matters (error handling, types) and concise everywhere else. That verbosity makes code auditable across teams.
- The
internal/directory is Go’s built-in enforcement of package boundaries. Use it liberally. - Contract-first API design prevents drift between documentation and implementation — your spec, types, and docs all share the same source of truth.
oapi-codegenwithstrict-servermode gives compile-time guarantees that your handler covers every route and returns correctly typed responses.- The standard library (
net/http,encoding/json,sync,testing) takes you very far. Reach for external frameworks only when you have a specific, justified need.
What to Explore Next
- Persistence — Replace the in-memory store with PostgreSQL using
pgxandsqlc. - Authentication — Add JWT validation middleware using
golang-jwt/jwt. - Configuration — Use
joho/godotenvor Go 1.21’sos.Getenvpattern for environment-based config. - Structured logging — Replace
log.Printfwithlog/slog(Go 1.21 standard library) for structured, leveled logs. - Deployment — Containerize with a multi-stage
Dockerfile, deploy to AWS Lambda via the Lambda Web Adapter, or run on EC2/ECS. - Goroutines and channels — Once comfortable with the basics, Go’s concurrency model is the next frontier:
go func(), channels,sync.WaitGroup, anderrgroup.
The standard Go documentation at pkg.go.dev and The Go Programming Language by Alan Donovan and Brian Kernighan remain the definitive references for going deeper.
Go is not the flashiest language in the ecosystem. It does not have macros, annotations, or a sprawling framework landscape. What it has is clarity, performance, and a production track record that is hard to argue with.
Write the spec. Generate the types. Ship the binary. Go for it.
