Back to Articles
TutorialsPublished October 17, 2026•34 min read

Go For It: Start Your Golang Journey

"A practical, beginner-to-intermediate guide to Go — covering core syntax, data structures, error handling, and building a fully contract-first CRUD REST API using oapi-codegen and Scalar API docs. Designed for beginners and developers migrating from Python, Node.js, or PHP."
Raphael Johnathan F. Flores

Raphael Johnathan F. Flores

2x AWS Certified Cloud Architect

#Go#Golang#REST API#CRUD#oapi-codegen#Scalar#OpenAPI#Backend#Beginners#Upskilling

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.go extension 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 main declares 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, &notFound) {
        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 server
  • oapi-codegen — generates Go server interfaces and types from your OpenAPI spec
  • Scalar — 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 points
  • internal/ — 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 schemas
  • StrictServerInterface — the interface your handler must implement
  • HandlerFromMux(...) — wires the interface to net/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. RWMutex allows 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:embed directives cannot traverse parent directories with relative paths (..). By declaring var OpenAPISpec []byte inside api/spec.go where openapi.yaml resides, 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: true in 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/http middleware 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-codegen with strict-server mode 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

  1. Persistence — Replace the in-memory store with PostgreSQL using pgx and sqlc.
  2. Authentication — Add JWT validation middleware using golang-jwt/jwt.
  3. Configuration — Use joho/godotenv or Go 1.21’s os.Getenv pattern for environment-based config.
  4. Structured logging — Replace log.Printf with log/slog (Go 1.21 standard library) for structured, leveled logs.
  5. Deployment — Containerize with a multi-stage Dockerfile, deploy to AWS Lambda via the Lambda Web Adapter, or run on EC2/ECS.
  6. Goroutines and channels — Once comfortable with the basics, Go’s concurrency model is the next frontier: go func(), channels, sync.WaitGroup, and errgroup.

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.