Building a backend that can genuinely withstand production traffic — with proper authentication, clean architecture, and robust error handling — is a challenge most Go tutorials skip. They show you a main.go file with five hundred lines of mixed handler logic and call it a tutorial.
This is not that article.
We are going to build a fully production-ready REST API using Go 1.26, following the latest industry standards and community best practices. By the end, you will have a deeply layered application with JWT-based authentication, full CRUD for a Products resource, structured logging, graceful shutdown, and a clean, testable codebase that mirrors what senior Go engineers build at scale.
Why Go for APIs in 2026?
Go remains one of the best choices for high-performance APIs. It offers:
- Static compilation — single binary deployments, trivially containerized.
- Native concurrency — goroutines and channels for unparalleled throughput.
-
Industry-grade standard library — the
net/httppackage received major routing enhancements in Go 1.22 that are now fully mature in Go 1.26. - Minimal runtime overhead — far lower memory footprint than Node.js or the JVM.
The Go 1.22+ router (net/http) introduced method-based routing and path parameters natively, eliminating the need for external routing libraries like gorilla/mux for most use cases. We will leverage this in our project.
The Architecture Diagram & Request Lifecycle
Before writing any code, visualize the full lifecycle of an HTTP request through our API:
[HTTP Request]
│
▼
1. [Router] (net/http ServeMux with method+path patterns)
│
▼
2. [Middleware Chain] (Logger → Rate Limiter → JWT Auth Guard)
│
▼
3. [Handler Layer] (Thin HTTP transport: decode request, call service, encode response)
│
▼
4. [Service Layer] (Business Logic, Validation, Transactions)
│
▼
5. [Repository Layer] (Abstracts all SQL queries via an interface)
│
▼
6. [Database] (PostgreSQL via pgx/v5 — the modern, high-performance Go Postgres driver)
│
▼
(Data flows back up through the layers)
│
▼
7. [Response Encoder] (Strict JSON output, never exposing internal fields)
│
▼
[HTTP Response]
This separation ensures every layer has a single responsibility. Your handlers never talk to the database. Your repositories have zero business logic.
The Project Structure
By the end of this tutorial, our project will be laid out as follows. Every folder has a single, clear responsibility.
go-products-api/
├── cmd/
│ └── api/
│ └── main.go # Entry point: wires everything together & starts server
├── internal/
│ ├── auth/
│ │ ├── jwt.go # JWT generation & validation logic
│ │ └── middleware.go # HTTP middleware for protecting routes
│ ├── config/
│ │ └── config.go # Reads environment variables into a typed Config struct
│ ├── database/
│ │ └── postgres.go # Initializes the pgx connection pool
│ ├── handler/
│ │ ├── auth_handler.go # Handles /login and /register HTTP endpoints
│ │ └── product_handler.go # Handles /products CRUD HTTP endpoints
│ ├── middleware/
│ │ └── logger.go # Structured request logging middleware
│ ├── model/
│ │ └── models.go # All domain structs: User, Product, request/response payloads
│ ├── repository/
│ │ ├── user_repository.go # SQL queries for Users
│ │ └── product_repository.go # SQL queries for Products
│ └── service/
│ ├── auth_service.go # Register/Login business logic (hashing, token gen)
│ └── product_service.go # Product CRUD business logic & validation
├── migrations/
│ ├── 001_create_users.sql
│ └── 002_create_products.sql
├── .env.example
└── go.mod
The internal/ package is a Go compiler-enforced convention — code inside it cannot be imported by any external packages, enforcing encapsulation.
Dependencies
Initialize the project and install our lean dependency set:
mkdir go-products-api && cd go-products-api
go mod init github.com/yourname/go-products-api
# Core dependencies
go get github.com/jackc/pgx/v5 # High-performance PostgreSQL driver
go get golang.org/x/crypto # bcrypt password hashing
go get github.com/golang-jwt/jwt/v5 # JWT v5 (latest spec-compliant, maintained library)
go get github.com/joho/godotenv # .env file loader for local development
go get golang.org/x/time/rate # Token-bucket rate limiter from the Go team
Note on Go 1.26: We deliberately avoid
gorilla/mux,gin, orechofor routing. The stdlibnet/httpServeMux — enhanced in Go 1.22 to supportMETHOD /path/{param}patterns — is fully sufficient and results in a leaner binary with fewer supply-chain risks.
Step 1: Configuration (internal/config/config.go)
Never hardcode credentials. A typed Config struct loaded from environment variables is the standard Go approach. This also makes testing straightforward — swap the env vars, get a different config.
// internal/config/config.go
package config
import (
"fmt"
"os"
"strconv"
)
// Config holds all application configuration, loaded from environment variables.
type Config struct {
Server ServerConfig
Database DatabaseConfig
JWT JWTConfig
}
type ServerConfig struct {
Port string
ReadTimeoutSec int
WriteTimeoutSec int
}
type DatabaseConfig struct {
DSN string // PostgreSQL Data Source Name
}
type JWTConfig struct {
Secret string
ExpiryHours int
}
// Load reads environment variables and returns a populated Config struct.
// It fails fast if required variables are missing.
func Load() (*Config, error) {
secret := os.Getenv("JWT_SECRET")
if secret == "" {
return nil, fmt.Errorf("config: JWT_SECRET environment variable is required")
}
dbDSN := os.Getenv("DATABASE_URL")
if dbDSN == "" {
return nil, fmt.Errorf("config: DATABASE_URL environment variable is required")
}
readTimeout, _ := strconv.Atoi(os.Getenv("SERVER_READ_TIMEOUT_SEC"))
if readTimeout == 0 {
readTimeout = 10
}
writeTimeout, _ := strconv.Atoi(os.Getenv("SERVER_WRITE_TIMEOUT_SEC"))
if writeTimeout == 0 {
writeTimeout = 30
}
jwtExpiry, _ := strconv.Atoi(os.Getenv("JWT_EXPIRY_HOURS"))
if jwtExpiry == 0 {
jwtExpiry = 24
}
port := os.Getenv("PORT")
if port == "" {
port = "8080"
}
return &Config{
Server: ServerConfig{
Port: port,
ReadTimeoutSec: readTimeout,
WriteTimeoutSec: writeTimeout,
},
Database: DatabaseConfig{DSN: dbDSN},
JWT: JWTConfig{Secret: secret, ExpiryHours: jwtExpiry},
}, nil
}
Step 2: Database Migrations
We keep migrations as plain SQL files checked into version control. This is the simplest, most portable approach.
migrations/001_create_users.sql:
CREATE TABLE IF NOT EXISTS users (
id BIGSERIAL PRIMARY KEY,
email TEXT UNIQUE NOT NULL,
password TEXT NOT NULL, -- bcrypt hash, NEVER plaintext
role TEXT NOT NULL DEFAULT 'user',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_users_email ON users(email);
migrations/002_create_products.sql:
CREATE TABLE IF NOT EXISTS products (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name TEXT NOT NULL,
description TEXT,
price NUMERIC(10, 2) NOT NULL CHECK (price >= 0),
stock INTEGER NOT NULL DEFAULT 0 CHECK (stock >= 0),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_products_user_id ON products(user_id);
Step 3: Domain Models (internal/model/models.go)
A central models file defines our pure Go structs. These are not tied to any database library or HTTP framework.
// internal/model/models.go
package model
import "time"
// User represents an authenticated application user.
type User struct {
ID int64 `json:"id"`
Email string `json:"email"`
Password string `json:"-"` // The "-" tag ensures the hash is NEVER serialized to JSON
Role string `json:"role"`
CreatedAt time.Time `json:"created_at"`
}
// Product is the core domain entity.
type Product struct {
ID int64 `json:"id"`
UserID int64 `json:"user_id"`
Name string `json:"name"`
Description string `json:"description,omitempty"`
Price float64 `json:"price"`
Stock int `json:"stock"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// --- Request Payloads (Incoming) ---
type RegisterRequest struct {
Email string `json:"email"`
Password string `json:"password"`
}
type LoginRequest struct {
Email string `json:"email"`
Password string `json:"password"`
}
type CreateProductRequest struct {
Name string `json:"name"`
Description string `json:"description"`
Price float64 `json:"price"`
Stock int `json:"stock"`
}
// UpdateProductRequest uses pointer fields so absent JSON keys are distinguishable
// from zero values. Only non-nil fields are written to the database on PATCH.
type UpdateProductRequest struct {
Name *string `json:"name"`
Description *string `json:"description"`
Price *float64 `json:"price"`
Stock *int `json:"stock"`
}
// --- Response Payloads (Outgoing) ---
type AuthResponse struct {
AccessToken string `json:"access_token"`
TokenType string `json:"token_type"`
User *User `json:"user"`
}
// APIError is the standard error response format for the API.
type APIError struct {
Code int `json:"code"`
Message string `json:"message"`
}
// PaginatedResponse wraps a list of items with pagination metadata.
type PaginatedResponse[T any] struct {
Data []T `json:"data"`
Total int64 `json:"total"`
Page int `json:"page"`
Limit int `json:"limit"`
}
// Claims represents the data embedded inside our JWT.
type Claims struct {
UserID int64 `json:"user_id"`
Email string `json:"email"`
Role string `json:"role"`
}
Key design decision: the
json:"-"tag onUser.Passwordis a critical security safeguard. Even if a handler accidentally returns aUserstruct directly, the bcrypt hash will never appear in the JSON output.
Step 4: JWT Authentication (internal/auth/jwt.go)
We use golang-jwt/jwt/v5, which implements the full RFC 7519 specification and is the community's maintained successor to the older dgrijalva/jwt-go package.
// internal/auth/jwt.go
package auth
import (
"errors"
"fmt"
"time"
"github.com/golang-jwt/jwt/v5"
"github.com/yourname/go-products-api/internal/model"
)
// jwtCustomClaims embeds the standard RegisteredClaims and adds our custom fields.
type jwtCustomClaims struct {
model.Claims
jwt.RegisteredClaims
}
// Manager handles all JWT operations using a typed struct, not a global variable.
type Manager struct {
secretKey []byte
expiryHours int
}
// NewManager creates a new JWT Manager.
func NewManager(secret string, expiryHours int) *Manager {
return &Manager{
secretKey: []byte(secret),
expiryHours: expiryHours,
}
}
// Generate creates a signed JWT string for a given user.
func (m *Manager) Generate(userID int64, email, role string) (string, error) {
claims := jwtCustomClaims{
Claims: model.Claims{
UserID: userID,
Email: email,
Role: role,
},
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Duration(m.expiryHours) * time.Hour)),
IssuedAt: jwt.NewNumericDate(time.Now()),
Issuer: "go-products-api",
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
signedToken, err := token.SignedString(m.secretKey)
if err != nil {
return "", fmt.Errorf("auth: failed to sign token: %w", err)
}
return signedToken, nil
}
// Validate parses and validates a JWT string, returning the embedded claims.
func (m *Manager) Validate(tokenStr string) (*model.Claims, error) {
token, err := jwt.ParseWithClaims(tokenStr, &jwtCustomClaims{}, func(token *jwt.Token) (any, error) {
// IMPORTANT: Always verify the signing method to prevent algorithm substitution attacks
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("auth: unexpected signing method: %v", token.Header["alg"])
}
return m.secretKey, nil
})
if err != nil {
if errors.Is(err, jwt.ErrTokenExpired) {
return nil, fmt.Errorf("auth: token has expired")
}
return nil, fmt.Errorf("auth: invalid token: %w", err)
}
claims, ok := token.Claims.(*jwtCustomClaims)
if !ok || !token.Valid {
return nil, fmt.Errorf("auth: token claims are invalid")
}
return &claims.Claims, nil
}
Step 5: Authentication Middleware (internal/auth/middleware.go)
The middleware extracts and validates the Bearer token from every protected request. It uses a context key to pass the claims downstream to the handlers — a clean, idiomatic Go pattern.
// internal/auth/middleware.go
package auth
import (
"context"
"net/http"
"strings"
"github.com/yourname/go-products-api/internal/model"
)
// contextKey is an unexported type to prevent context key collisions.
type contextKey string
const claimsContextKey contextKey = "jwt_claims"
// Middleware returns an http.Handler middleware that validates the JWT on every request.
func (m *Manager) Middleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
authHeader := r.Header.Get("Authorization")
if authHeader == "" {
writeError(w, http.StatusUnauthorized, "authorization header is required")
return
}
parts := strings.SplitN(authHeader, " ", 2)
if len(parts) != 2 || !strings.EqualFold(parts[0], "bearer") {
writeError(w, http.StatusUnauthorized, "authorization header format must be: Bearer {token}")
return
}
claims, err := m.Validate(parts[1])
if err != nil {
writeError(w, http.StatusUnauthorized, err.Error())
return
}
// Inject the validated claims into the request context
ctx := context.WithValue(r.Context(), claimsContextKey, claims)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
// ClaimsFromContext retrieves the validated JWT claims from the request context.
// Returns nil if not present (i.e., called on an unprotected route).
func ClaimsFromContext(ctx context.Context) *model.Claims {
claims, _ := ctx.Value(claimsContextKey).(*model.Claims)
return claims
}
// RequireRole returns a middleware that enforces a specific user role.
func (m *Manager) RequireRole(role string, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
claims := ClaimsFromContext(r.Context())
if claims == nil || claims.Role != role {
writeError(w, http.StatusForbidden, "you do not have permission to perform this action")
return
}
next.ServeHTTP(w, r)
})
}
Step 6: The Repository Layer
Repositories encapsulate all SQL. They depend on an interface. This makes your services testable without a real database — you can mock the repository.
The User Repository (internal/repository/user_repository.go)
// internal/repository/user_repository.go
package repository
import (
"context"
"errors"
"fmt"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/yourname/go-products-api/internal/model"
)
// UserRepository defines the contract for User data access.
type UserRepository interface {
Create(ctx context.Context, email, hashedPassword string) (*model.User, error)
FindByEmail(ctx context.Context, email string) (*model.User, error)
FindByID(ctx context.Context, id int64) (*model.User, error)
}
type pgUserRepository struct {
pool *pgxpool.Pool
}
// NewUserRepository creates a new PostgreSQL-backed UserRepository.
func NewUserRepository(pool *pgxpool.Pool) UserRepository {
return &pgUserRepository{pool: pool}
}
func (r *pgUserRepository) Create(ctx context.Context, email, hashedPassword string) (*model.User, error) {
query := `
INSERT INTO users (email, password)
VALUES ($1, $2)
RETURNING id, email, role, created_at`
var user model.User
err := r.pool.QueryRow(ctx, query, email, hashedPassword).Scan(
&user.ID, &user.Email, &user.Role, &user.CreatedAt,
)
if err != nil {
return nil, fmt.Errorf("userRepo.Create: %w", err)
}
return &user, nil
}
func (r *pgUserRepository) FindByEmail(ctx context.Context, email string) (*model.User, error) {
query := `SELECT id, email, password, role, created_at FROM users WHERE email = $1`
var user model.User
err := r.pool.QueryRow(ctx, query, email).Scan(
&user.ID, &user.Email, &user.Password, &user.Role, &user.CreatedAt,
)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil // Not found is not an error at this layer
}
return nil, fmt.Errorf("userRepo.FindByEmail: %w", err)
}
return &user, nil
}
func (r *pgUserRepository) FindByID(ctx context.Context, id int64) (*model.User, error) {
query := `SELECT id, email, role, created_at FROM users WHERE id = $1`
var user model.User
err := r.pool.QueryRow(ctx, query, id).Scan(
&user.ID, &user.Email, &user.Role, &user.CreatedAt,
)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
return nil, fmt.Errorf("userRepo.FindByID: %w", err)
}
return &user, nil
}
The Product Repository (internal/repository/product_repository.go)
// internal/repository/product_repository.go
package repository
import (
"context"
"errors"
"fmt"
"strings"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/yourname/go-products-api/internal/model"
)
// ProductRepository defines the contract for Product data access.
type ProductRepository interface {
Create(ctx context.Context, userID int64, req *model.CreateProductRequest) (*model.Product, error)
FindAll(ctx context.Context, page, limit int) ([]*model.Product, int64, error)
FindByID(ctx context.Context, id int64) (*model.Product, error)
Update(ctx context.Context, id int64, req *model.UpdateProductRequest) (*model.Product, error)
Delete(ctx context.Context, id int64) error
}
type pgProductRepository struct {
pool *pgxpool.Pool
}
// NewProductRepository creates a new PostgreSQL-backed ProductRepository.
func NewProductRepository(pool *pgxpool.Pool) ProductRepository {
return &pgProductRepository{pool: pool}
}
func (r *pgProductRepository) Create(ctx context.Context, userID int64, req *model.CreateProductRequest) (*model.Product, error) {
query := `
INSERT INTO products (user_id, name, description, price, stock)
VALUES ($1, $2, $3, $4, $5)
RETURNING id, user_id, name, description, price, stock, created_at, updated_at`
var p model.Product
err := r.pool.QueryRow(ctx, query, userID, req.Name, req.Description, req.Price, req.Stock).Scan(
&p.ID, &p.UserID, &p.Name, &p.Description, &p.Price, &p.Stock, &p.CreatedAt, &p.UpdatedAt,
)
if err != nil {
return nil, fmt.Errorf("productRepo.Create: %w", err)
}
return &p, nil
}
func (r *pgProductRepository) FindAll(ctx context.Context, page, limit int) ([]*model.Product, int64, error) {
// Use a Common Table Expression to get both the products and total count in a single query
query := `
WITH product_count AS (SELECT COUNT(*) FROM products)
SELECT id, user_id, name, description, price, stock, created_at, updated_at,
(SELECT * FROM product_count)
FROM products
ORDER BY created_at DESC
LIMIT $1 OFFSET $2`
offset := (page - 1) * limit
rows, err := r.pool.Query(ctx, query, limit, offset)
if err != nil {
return nil, 0, fmt.Errorf("productRepo.FindAll: %w", err)
}
defer rows.Close()
var products []*model.Product
var total int64
for rows.Next() {
var p model.Product
if err := rows.Scan(&p.ID, &p.UserID, &p.Name, &p.Description, &p.Price, &p.Stock, &p.CreatedAt, &p.UpdatedAt, &total); err != nil {
return nil, 0, fmt.Errorf("productRepo.FindAll scan: %w", err)
}
products = append(products, &p)
}
return products, total, rows.Err()
}
func (r *pgProductRepository) FindByID(ctx context.Context, id int64) (*model.Product, error) {
query := `SELECT id, user_id, name, description, price, stock, created_at, updated_at FROM products WHERE id = $1`
var p model.Product
err := r.pool.QueryRow(ctx, query, id).Scan(
&p.ID, &p.UserID, &p.Name, &p.Description, &p.Price, &p.Stock, &p.CreatedAt, &p.UpdatedAt,
)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
return nil, fmt.Errorf("productRepo.FindByID: %w", err)
}
return &p, nil
}
func (r *pgProductRepository) Update(ctx context.Context, id int64, req *model.UpdateProductRequest) (*model.Product, error) {
// Build the SET clause dynamically — only include fields the client actually sent.
// nil pointer = "field not provided by client" = skip it in SQL.
setClauses := []string{}
args := []any{}
argIdx := 1
if req.Name != nil {
setClauses = append(setClauses, fmt.Sprintf("name = $%d", argIdx))
args = append(args, *req.Name)
argIdx++
}
if req.Description != nil {
setClauses = append(setClauses, fmt.Sprintf("description = $%d", argIdx))
args = append(args, *req.Description)
argIdx++
}
if req.Price != nil {
setClauses = append(setClauses, fmt.Sprintf("price = $%d", argIdx))
args = append(args, *req.Price)
argIdx++
}
if req.Stock != nil {
setClauses = append(setClauses, fmt.Sprintf("stock = $%d", argIdx))
args = append(args, *req.Stock)
argIdx++
}
setClauses = append(setClauses, "updated_at = NOW()")
args = append(args, id)
query := fmt.Sprintf(`
UPDATE products
SET %s
WHERE id = $%d
RETURNING id, user_id, name, description, price, stock, created_at, updated_at`,
strings.Join(setClauses, ", "), argIdx,
)
var p model.Product
err := r.pool.QueryRow(ctx, query, args...).Scan(
&p.ID, &p.UserID, &p.Name, &p.Description, &p.Price, &p.Stock, &p.CreatedAt, &p.UpdatedAt,
)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
return nil, fmt.Errorf("productRepo.Update: %w", err)
}
return &p, nil
}
func (r *pgProductRepository) Delete(ctx context.Context, id int64) error {
result, err := r.pool.Exec(ctx, `DELETE FROM products WHERE id = $1`, id)
if err != nil {
return fmt.Errorf("productRepo.Delete: %w", err)
}
if result.RowsAffected() == 0 {
return fmt.Errorf("productRepo.Delete: no product found with id %d", id)
}
return nil
}
Step 7: The Service Layer (Business Logic)
The service layer is the application's brain. It runs all business rules and is entirely unaware of HTTP.
Auth Service (internal/service/auth_service.go)
// internal/service/auth_service.go
package service
import (
"context"
"fmt"
"strings"
"golang.org/x/crypto/bcrypt"
"github.com/yourname/go-products-api/internal/auth"
"github.com/yourname/go-products-api/internal/model"
"github.com/yourname/go-products-api/internal/repository"
)
// AuthService defines the contract for authentication business logic.
type AuthService interface {
Register(ctx context.Context, req *model.RegisterRequest) (*model.AuthResponse, error)
Login(ctx context.Context, req *model.LoginRequest) (*model.AuthResponse, error)
}
type authService struct {
userRepo repository.UserRepository
jwtManager *auth.Manager
}
// NewAuthService creates a new AuthService.
func NewAuthService(userRepo repository.UserRepository, jwtManager *auth.Manager) AuthService {
return &authService{userRepo: userRepo, jwtManager: jwtManager}
}
func (s *authService) Register(ctx context.Context, req *model.RegisterRequest) (*model.AuthResponse, error) {
// --- Validation ---
req.Email = strings.TrimSpace(strings.ToLower(req.Email))
if req.Email == "" || !strings.Contains(req.Email, "@") {
return nil, fmt.Errorf("validation: a valid email address is required")
}
if len(req.Password) < 8 {
return nil, fmt.Errorf("validation: password must be at least 8 characters long")
}
// --- Check for existing user ---
existing, err := s.userRepo.FindByEmail(ctx, req.Email)
if err != nil {
return nil, fmt.Errorf("register: failed to check existing user: %w", err)
}
if existing != nil {
return nil, fmt.Errorf("validation: a user with this email already exists")
}
// --- Hash the password using bcrypt at cost 12 ---
// Cost 12 is the modern recommendation: secure enough, not so slow it impacts UX.
hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), 12)
if err != nil {
return nil, fmt.Errorf("register: failed to hash password: %w", err)
}
user, err := s.userRepo.Create(ctx, req.Email, string(hash))
if err != nil {
return nil, fmt.Errorf("register: failed to create user: %w", err)
}
token, err := s.jwtManager.Generate(user.ID, user.Email, user.Role)
if err != nil {
return nil, err
}
return &model.AuthResponse{
AccessToken: token,
TokenType: "Bearer",
User: user,
}, nil
}
func (s *authService) Login(ctx context.Context, req *model.LoginRequest) (*model.AuthResponse, error) {
req.Email = strings.TrimSpace(strings.ToLower(req.Email))
user, err := s.userRepo.FindByEmail(ctx, req.Email)
if err != nil {
return nil, fmt.Errorf("login: database error: %w", err)
}
// IMPORTANT: Return the same generic error for both "user not found" and
// "wrong password" to prevent user enumeration attacks.
if user == nil {
return nil, fmt.Errorf("auth: invalid email or password")
}
if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(req.Password)); err != nil {
return nil, fmt.Errorf("auth: invalid email or password")
}
token, err := s.jwtManager.Generate(user.ID, user.Email, user.Role)
if err != nil {
return nil, err
}
return &model.AuthResponse{
AccessToken: token,
TokenType: "Bearer",
User: user,
}, nil
}
Security note: The identical error message for "user not found" and "wrong password" is intentional. Returning
"user not found"for invalid emails would allow attackers to cheaply enumerate valid email addresses registered on your platform.
Product Service (internal/service/product_service.go)
// internal/service/product_service.go
package service
import (
"context"
"fmt"
"strings"
"github.com/yourname/go-products-api/internal/model"
"github.com/yourname/go-products-api/internal/repository"
)
// ProductService defines the contract for product business logic.
type ProductService interface {
Create(ctx context.Context, userID int64, req *model.CreateProductRequest) (*model.Product, error)
GetAll(ctx context.Context, page, limit int) (*model.PaginatedResponse[model.Product], error)
GetByID(ctx context.Context, id int64) (*model.Product, error)
Update(ctx context.Context, requesterID, productID int64, req *model.UpdateProductRequest) (*model.Product, error)
Delete(ctx context.Context, requesterID, productID int64) error
}
type productService struct {
productRepo repository.ProductRepository
}
// NewProductService creates a new ProductService.
func NewProductService(productRepo repository.ProductRepository) ProductService {
return &productService{productRepo: productRepo}
}
func (s *productService) Create(ctx context.Context, userID int64, req *model.CreateProductRequest) (*model.Product, error) {
req.Name = strings.TrimSpace(req.Name)
if req.Name == "" {
return nil, fmt.Errorf("validation: product name is required")
}
if req.Price < 0 {
return nil, fmt.Errorf("validation: price cannot be negative")
}
if req.Stock < 0 {
return nil, fmt.Errorf("validation: stock cannot be negative")
}
return s.productRepo.Create(ctx, userID, req)
}
func (s *productService) GetAll(ctx context.Context, page, limit int) (*model.PaginatedResponse[model.Product], error) {
if page < 1 {
page = 1
}
if limit < 1 || limit > 100 {
limit = 20
}
products, total, err := s.productRepo.FindAll(ctx, page, limit)
if err != nil {
return nil, fmt.Errorf("productService.GetAll: %w", err)
}
// Dereference pointers for the response
items := make([]model.Product, len(products))
for i, p := range products {
items[i] = *p
}
return &model.PaginatedResponse[model.Product]{
Data: items,
Total: total,
Page: page,
Limit: limit,
}, nil
}
func (s *productService) GetByID(ctx context.Context, id int64) (*model.Product, error) {
product, err := s.productRepo.FindByID(ctx, id)
if err != nil {
return nil, fmt.Errorf("productService.GetByID: %w", err)
}
if product == nil {
return nil, fmt.Errorf("not_found: product with id %d does not exist", id)
}
return product, nil
}
func (s *productService) Update(ctx context.Context, requesterID, productID int64, req *model.UpdateProductRequest) (*model.Product, error) {
// 1. Authorization: Verify the requester owns this product (prevents IDOR)
existing, err := s.productRepo.FindByID(ctx, productID)
if err != nil {
return nil, err
}
if existing == nil {
return nil, fmt.Errorf("not_found: product with id %d does not exist", productID)
}
if existing.UserID != requesterID {
return nil, fmt.Errorf("forbidden: you do not own this product")
}
// 2. Validate only sent fields (nil = field was omitted from the request body)
if req.Name != nil {
trimmed := strings.TrimSpace(*req.Name)
if trimmed == "" {
return nil, fmt.Errorf("validation: product name cannot be empty")
}
req.Name = &trimmed
}
if req.Price != nil && *req.Price < 0 {
return nil, fmt.Errorf("validation: price cannot be negative")
}
if req.Stock != nil && *req.Stock < 0 {
return nil, fmt.Errorf("validation: stock cannot be negative")
}
// 3. Reject a completely empty PATCH body
if req.Name == nil && req.Description == nil && req.Price == nil && req.Stock == nil {
return nil, fmt.Errorf("validation: no fields provided for update")
}
return s.productRepo.Update(ctx, productID, req)
}
func (s *productService) Delete(ctx context.Context, requesterID, productID int64) error {
// Authorization check
existing, err := s.productRepo.FindByID(ctx, productID)
if err != nil {
return err
}
if existing == nil {
return fmt.Errorf("not_found: product with id %d does not exist", productID)
}
if existing.UserID != requesterID {
return fmt.Errorf("forbidden: you do not own this product")
}
return s.productRepo.Delete(ctx, productID)
}
Step 8: The Handler Layer (HTTP Transport)
Handlers are thin. They decode requests, call the service, handle errors by converting them into HTTP status codes, and encode responses.
The Helper (internal/auth/middleware.go additions)
// Add these helper functions to internal/auth/middleware.go (or a shared response package)
import "encoding/json"
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
}
func writeError(w http.ResponseWriter, status int, message string) {
writeJSON(w, status, model.APIError{Code: status, Message: message})
}
Auth Handler (internal/handler/auth_handler.go)
// internal/handler/auth_handler.go
package handler
import (
"encoding/json"
"net/http"
"strings"
"github.com/yourname/go-products-api/internal/auth"
"github.com/yourname/go-products-api/internal/model"
"github.com/yourname/go-products-api/internal/service"
)
// AuthHandler handles auth-related HTTP requests.
type AuthHandler struct {
authSvc service.AuthService
}
// NewAuthHandler creates a new AuthHandler.
func NewAuthHandler(authSvc service.AuthService) *AuthHandler {
return &AuthHandler{authSvc: authSvc}
}
func (h *AuthHandler) Register(w http.ResponseWriter, r *http.Request) {
var req model.RegisterRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
auth.WriteError(w, http.StatusBadRequest, "invalid request body: "+err.Error())
return
}
resp, err := h.authSvc.Register(r.Context(), &req)
if err != nil {
if isValidationErr(err) {
auth.WriteError(w, http.StatusUnprocessableEntity, err.Error())
return
}
auth.WriteError(w, http.StatusInternalServerError, "registration failed")
return
}
auth.WriteJSON(w, http.StatusCreated, resp)
}
func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) {
var req model.LoginRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
auth.WriteError(w, http.StatusBadRequest, "invalid request body: "+err.Error())
return
}
resp, err := h.authSvc.Login(r.Context(), &req)
if err != nil {
auth.WriteError(w, http.StatusUnauthorized, err.Error())
return
}
auth.WriteJSON(w, http.StatusOK, resp)
}
// isValidationErr checks if the error originated from a validation rule.
func isValidationErr(err error) bool {
return strings.HasPrefix(err.Error(), "validation:")
}
Product Handler (internal/handler/product_handler.go)
// internal/handler/product_handler.go
package handler
import (
"encoding/json"
"net/http"
"strconv"
"strings"
"github.com/yourname/go-products-api/internal/auth"
"github.com/yourname/go-products-api/internal/model"
"github.com/yourname/go-products-api/internal/service"
)
// ProductHandler handles product CRUD HTTP requests.
type ProductHandler struct {
productSvc service.ProductService
}
// NewProductHandler creates a new ProductHandler.
func NewProductHandler(productSvc service.ProductService) *ProductHandler {
return &ProductHandler{productSvc: productSvc}
}
func (h *ProductHandler) Create(w http.ResponseWriter, r *http.Request) {
claims := auth.ClaimsFromContext(r.Context())
var req model.CreateProductRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
auth.WriteError(w, http.StatusBadRequest, "invalid request body")
return
}
product, err := h.productSvc.Create(r.Context(), claims.UserID, &req)
if err != nil {
if isValidationErr(err) {
auth.WriteError(w, http.StatusUnprocessableEntity, err.Error())
return
}
auth.WriteError(w, http.StatusInternalServerError, "failed to create product")
return
}
auth.WriteJSON(w, http.StatusCreated, product)
}
func (h *ProductHandler) GetAll(w http.ResponseWriter, r *http.Request) {
page, _ := strconv.Atoi(r.URL.Query().Get("page"))
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
result, err := h.productSvc.GetAll(r.Context(), page, limit)
if err != nil {
auth.WriteError(w, http.StatusInternalServerError, "failed to retrieve products")
return
}
auth.WriteJSON(w, http.StatusOK, result)
}
func (h *ProductHandler) GetByID(w http.ResponseWriter, r *http.Request) {
// Go 1.22+: PathValue extracts {id} from the route pattern
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil {
auth.WriteError(w, http.StatusBadRequest, "invalid product id")
return
}
product, err := h.productSvc.GetByID(r.Context(), id)
if err != nil {
if strings.HasPrefix(err.Error(), "not_found:") {
auth.WriteError(w, http.StatusNotFound, err.Error())
return
}
auth.WriteError(w, http.StatusInternalServerError, "failed to retrieve product")
return
}
auth.WriteJSON(w, http.StatusOK, product)
}
func (h *ProductHandler) Update(w http.ResponseWriter, r *http.Request) {
claims := auth.ClaimsFromContext(r.Context())
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil {
auth.WriteError(w, http.StatusBadRequest, "invalid product id")
return
}
var req model.UpdateProductRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
auth.WriteError(w, http.StatusBadRequest, "invalid request body")
return
}
product, err := h.productSvc.Update(r.Context(), claims.UserID, id, &req)
if err != nil {
switch {
case strings.HasPrefix(err.Error(), "not_found:"):
auth.WriteError(w, http.StatusNotFound, err.Error())
case strings.HasPrefix(err.Error(), "forbidden:"):
auth.WriteError(w, http.StatusForbidden, err.Error())
case isValidationErr(err):
auth.WriteError(w, http.StatusUnprocessableEntity, err.Error())
default:
auth.WriteError(w, http.StatusInternalServerError, "failed to update product")
}
return
}
auth.WriteJSON(w, http.StatusOK, product)
}
func (h *ProductHandler) Delete(w http.ResponseWriter, r *http.Request) {
claims := auth.ClaimsFromContext(r.Context())
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil {
auth.WriteError(w, http.StatusBadRequest, "invalid product id")
return
}
if err := h.productSvc.Delete(r.Context(), claims.UserID, id); err != nil {
switch {
case strings.HasPrefix(err.Error(), "not_found:"):
auth.WriteError(w, http.StatusNotFound, err.Error())
case strings.HasPrefix(err.Error(), "forbidden:"):
auth.WriteError(w, http.StatusForbidden, err.Error())
default:
auth.WriteError(w, http.StatusInternalServerError, "failed to delete product")
}
return
}
w.WriteHeader(http.StatusNoContent) // 204: Success with no body
}
Step 9: Request Logging Middleware (internal/middleware/logger.go)
Structured logging is non-negotiable in production. We use Go's native log/slog package (introduced in Go 1.21 and now standard in Go 1.26) for JSON log output.
// internal/middleware/logger.go
package middleware
import (
"log/slog"
"net/http"
"time"
)
// responseWriter wraps http.ResponseWriter to intercept the status code.
type responseWriter struct {
http.ResponseWriter
statusCode int
}
func (rw *responseWriter) WriteHeader(code int) {
rw.statusCode = code
rw.ResponseWriter.WriteHeader(code)
}
// Logger returns a structured logging middleware using the stdlib slog package.
func Logger(logger *slog.Logger) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
wrapped := &responseWriter{ResponseWriter: w, statusCode: http.StatusOK}
next.ServeHTTP(wrapped, r)
logger.Info("request completed",
"method", r.Method,
"path", r.URL.Path,
"status", wrapped.statusCode,
"duration_ms", time.Since(start).Milliseconds(),
"remote_addr", r.RemoteAddr,
)
})
}
}
Step 10: The Entry Point — Wiring it All Together (cmd/api/main.go)
The main.go file is the composition root. It wires together all dependencies and starts the server with proper configuration for production, including graceful shutdown.
// cmd/api/main.go
package main
import (
"context"
"fmt"
"log/slog"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"github.com/joho/godotenv"
"github.com/yourname/go-products-api/internal/auth"
"github.com/yourname/go-products-api/internal/config"
"github.com/yourname/go-products-api/internal/database"
"github.com/yourname/go-products-api/internal/handler"
"github.com/yourname/go-products-api/internal/middleware"
"github.com/yourname/go-products-api/internal/repository"
"github.com/yourname/go-products-api/internal/service"
)
func main() {
// 1. Load env and initialize structured JSON logger
_ = godotenv.Load()
logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo}))
slog.SetDefault(logger)
// 2. Load typed configuration (fails fast on missing env vars)
cfg, err := config.Load()
if err != nil {
logger.Error("failed to load configuration", "error", err)
os.Exit(1)
}
// 3. Initialize the PostgreSQL connection pool
pool, err := database.NewPool(context.Background(), cfg.Database.DSN)
if err != nil {
logger.Error("failed to connect to database", "error", err)
os.Exit(1)
}
defer pool.Close()
// 4. Wire dependencies (Dependency Injection, manually)
jwtManager := auth.NewManager(cfg.JWT.Secret, cfg.JWT.ExpiryHours)
userRepo := repository.NewUserRepository(pool)
productRepo := repository.NewProductRepository(pool)
authSvc := service.NewAuthService(userRepo, jwtManager)
productSvc := service.NewProductService(productRepo)
authHandler := handler.NewAuthHandler(authSvc)
productHandler := handler.NewProductHandler(productSvc)
// 5. Register routes using the Go 1.22+ enhanced ServeMux
mux := http.NewServeMux()
// Public routes
mux.HandleFunc("POST /api/v1/auth/register", authHandler.Register)
mux.HandleFunc("POST /api/v1/auth/login", authHandler.Login)
// Protected routes — jwt.Middleware wraps the handler
mux.Handle("GET /api/v1/products", jwtManager.Middleware(http.HandlerFunc(productHandler.GetAll)))
mux.Handle("GET /api/v1/products/{id}", jwtManager.Middleware(http.HandlerFunc(productHandler.GetByID)))
mux.Handle("POST /api/v1/products", jwtManager.Middleware(http.HandlerFunc(productHandler.Create)))
mux.Handle("PATCH /api/v1/products/{id}", jwtManager.Middleware(http.HandlerFunc(productHandler.Update)))
mux.Handle("DELETE /api/v1/products/{id}", jwtManager.Middleware(http.HandlerFunc(productHandler.Delete)))
// 6. Apply global middleware (outermost = last to execute for the request, first for the response)
loggedMux := middleware.Logger(logger)(mux)
// 7. Configure the server with strict timeouts — essential for production
server := &http.Server{
Addr: fmt.Sprintf(":%s", cfg.Server.Port),
Handler: loggedMux,
ReadTimeout: time.Duration(cfg.Server.ReadTimeoutSec) * time.Second,
WriteTimeout: time.Duration(cfg.Server.WriteTimeoutSec) * time.Second,
IdleTimeout: 120 * time.Second,
}
// 8. Start server in a goroutine and listen for OS shutdown signals
go func() {
logger.Info("server starting", "port", cfg.Server.Port)
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
logger.Error("server failed to start", "error", err)
os.Exit(1)
}
}()
// Block until we receive SIGINT or SIGTERM (e.g., from `docker stop` or Ctrl+C)
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
// 9. Graceful shutdown: give in-flight requests 30 seconds to complete
logger.Info("server shutting down gracefully...")
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if err := server.Shutdown(ctx); err != nil {
logger.Error("server forced to shut down", "error", err)
}
logger.Info("server stopped")
}
Step 11: Database Connection Pool (internal/database/postgres.go)
// internal/database/postgres.go
package database
import (
"context"
"fmt"
"github.com/jackc/pgx/v5/pgxpool"
)
// NewPool creates and validates a new pgx connection pool.
func NewPool(ctx context.Context, dsn string) (*pgxpool.Pool, error) {
poolConfig, err := pgxpool.ParseConfig(dsn)
if err != nil {
return nil, fmt.Errorf("database: failed to parse DSN: %w", err)
}
// Production-ready pool configuration
poolConfig.MaxConns = 25
poolConfig.MinConns = 5
pool, err := pgxpool.NewWithConfig(ctx, poolConfig)
if err != nil {
return nil, fmt.Errorf("database: failed to create connection pool: %w", err)
}
// Validate connection on startup
if err := pool.Ping(ctx); err != nil {
return nil, fmt.Errorf("database: failed to ping postgres: %w", err)
}
return pool, nil
}
Running and Testing the API
The .env.example file
PORT=8080
DATABASE_URL=postgres://user:password@localhost:5432/go_products_api?sslmode=disable
JWT_SECRET=your-super-secret-key-change-this-in-production
JWT_EXPIRY_HOURS=24
SERVER_READ_TIMEOUT_SEC=10
SERVER_WRITE_TIMEOUT_SEC=30
Running the API
cp .env.example .env
# Edit .env with your PostgreSQL credentials
psql -U user -d go_products_api -f migrations/001_create_users.sql
psql -U user -d go_products_api -f migrations/002_create_products.sql
go run ./cmd/api/
Testing with cURL
1. Register a new user:
curl -s -X POST http://localhost:8080/api/v1/auth/register \
-H "Content-Type: application/json" \
-d '{"email":"dev@example.com","password":"securepassword123"}' | jq
2. Login and capture the token:
TOKEN=$(curl -s -X POST http://localhost:8080/api/v1/auth/login \
-H "Content-Type: application/json" \
-d '{"email":"dev@example.com","password":"securepassword123"}' | jq -r '.access_token')
3. Create a product (authenticated):
curl -s -X POST http://localhost:8080/api/v1/products \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"name":"Mechanical Keyboard","description":"TKL layout, Cherry MX Blue switches","price":149.99,"stock":42}' | jq
4. Get all products (paginated):
curl -s "http://localhost:8080/api/v1/products?page=1&limit=10" \
-H "Authorization: Bearer $TOKEN" | jq
5. Partial update a product (PATCH — only send fields you want to change):
# Update only the price — name, description, and stock are untouched
curl -s -X PATCH http://localhost:8080/api/v1/products/1 \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"price": 179.99}' | jq
# Update multiple fields at once
curl -s -X PATCH http://localhost:8080/api/v1/products/1 \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"name":"Mechanical Keyboard Pro","stock":38}' | jq
6. Delete a product:
curl -s -X DELETE http://localhost:8080/api/v1/products/1 \
-H "Authorization: Bearer $TOKEN"
# Returns 204 No Content on success
Key Production Best Practices Applied
| Practice | Where Applied | Why It Matters |
|---|---|---|
| Typed Config w/ fail-fast | config.Load() |
Catches missing credentials before the first request |
| bcrypt cost 12 | auth_service.Register() |
Modern industry-standard compute cost |
| User enumeration defense | auth_service.Login() |
Same error for bad email and bad password |
json:"-" on passwords |
model.User |
Prevents accidental password hash leakage in API responses |
| Algorithm verification in JWT | jwt.Validate() |
Prevents alg=none and algorithm substitution attacks |
| Unexported context key type | auth.contextKey |
Prevents context key collisions across packages |
| Server read/write timeouts | main.go http.Server |
Prevents slow-loris and resource exhaustion attacks |
| Graceful shutdown | main.go signal handling |
Zero-downtime deploys and clean container stops |
| Ownership checks in service | product_service.Update/Delete |
Prevents IDOR (Insecure Direct Object Reference) vulnerabilities |
| Structured JSON logging (slog) | middleware/logger.go |
Machine-parseable logs for Datadog, Loki, CloudWatch |
| Connection pool tuning | database/postgres.go |
Prevents pool exhaustion under high traffic |
internal/ package |
Entire project | Go compiler enforces encapsulation |
RETURNING clauses in SQL |
All repository mutations | Eliminates unnecessary follow-up SELECT queries |
| CTE for paginated count | productRepo.FindAll() |
Single database round-trip for data + count |
Conclusion
We have built a full-featured, production-ready REST API in Go 1.26 from the ground up. Every layer has a single responsibility, every security concern has been addressed, and the architecture is entirely testable without a running database.
By leveraging the modern stdlib router, JWT v5, pgx/v5, bcrypt, and slog, we have kept the dependency surface minimal while achieving what most enterprise applications need from day one.
The patterns established here — typed config, interface-backed repositories, thin handlers, and graceful shutdown — are the same ones powering Go services at Google, Cloudflare, and Stripe. They scale from your laptop to millions of requests per day without a rewrite.
Did you find this helpful? Follow for more deep-dives into production Go patterns. Drop a comment with the architecture challenge you'd like covered next — testing strategies, observability with OpenTelemetry, or building a real-time WebSocket layer on top of this API.
Top comments (0)