Back to writing
Joel Amoako
Joel Amoako

Patterns I Use in Every Go REST API

Patterns I Use in Every Go REST API

After building DonatePal and the REST API Starter, I’ve stopped experimenting with frameworks. These are the choices I make on every new Go API.

Chi for Routing

I’ve used gorilla/mux, Gin, Echo, and Fiber. Chi is what I keep coming back to. It’s compatible with net/http, so any standard library middleware works without adapters. No framework lock-in.

r := chi.NewRouter()
r.Use(middleware.Logger)
r.Use(middleware.Recoverer)
r.Use(middleware.Timeout(60 * time.Second))

r.Route("/api/v1", func(r chi.Router) {
    r.Get("/fundraisers", listFundraisers)
    r.Post("/fundraisers", createFundraiser)
    r.Get("/fundraisers/{id}", getFundraiser)
})

Middleware is composable. Auth on /admin routes but not /public? One line.

GORM With Caution

GORM is convenient and hides what’s happening at the database level. I use it for straightforward CRUD and migrations, then drop to raw SQL for joins, aggregations, or anything performance-sensitive.

// Fine for simple queries
db.Where("status = ?", "active").Find(&fundraisers)

// Complex queries in raw SQL
db.Raw(`SELECT f.*, SUM(d.amount) as total
    FROM fundraisers f
    LEFT JOIN donations d ON f.id = d.fundraiser_id
    GROUP BY f.id`).Scan(&results)

GORM’s migration system is fine for development. In production I use golang-migrate for explicit control over schema changes.

Environment Configuration

godotenv for local development. Viper when I need config files or remote config sources.

// .env for local dev
DB_HOST=localhost
DB_PORT=3306
DB_NAME=donatepal
API_PORT=8080

// Load in main.go
godotenv.Load()
port := os.Getenv("API_PORT")

Never hardcode configuration. Future you, deploying to a different environment, will be grateful.

Structured Logging With Logrus

fmt.Println is not logging. Logrus gives structured fields, log levels, and formatters.

log.WithFields(log.Fields{
    "fundraiser_id": id,
    "amount":        amount,
    "donor":         donor.Email,
}).Info("Donation received")

Switch to JSON formatting in production so your monitoring tools can parse logs. The default text formatter is readable enough locally.

Project Structure

.
├── cmd/            # Entry points
├── config/         # Nginx, deployment configs
├── handlers/       # HTTP handlers
├── models/         # Database models
├── middleware/     # Custom middleware
├── .env            # Local config
├── Makefile        # Build commands
└── autoreload.sh   # Dev auto-reload

Go doesn’t need deep nesting. A handlers package, models, and middleware cover most small-to-medium APIs. Split further when you have a real reason to.

Auto-Reload in Development

I use entr to watch for file changes and restart automatically:

find . -name '*.go' | entr -r go run cmd/main.go

Wrapped in autoreload.sh in the starter kit. Small improvement that compounds across a development session.

What I Skip

Dependency injection frameworks. Pass dependencies explicitly. A constructor that takes *gorm.DB and returns a handler struct is enough.

Route code generation. The routing layer is small enough to write by hand. Generating it adds complexity without benefit at this scale.

Microservice patterns for monolith-sized projects. If your API fits in one binary, ship one binary.

These patterns are in the REST API Starter. Fork it, delete what you don’t need, and build from there.