DEV Community

Cover image for How to Build a Production-Ready REST API in Go 1.26 with Full CRUD & JWT Authentication
Md Murtuza Hussain
Md Murtuza Hussain

Posted on

How to Build a Production-Ready REST API in Go 1.26 with Full CRUD & JWT Authentication

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/http package 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]
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Note on Go 1.26: We deliberately avoid gorilla/mux, gin, or echo for routing. The stdlib net/http ServeMux — enhanced in Go 1.22 to support METHOD /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
}
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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"`
}
Enter fullscreen mode Exit fullscreen mode

Key design decision: the json:"-" tag on User.Password is a critical security safeguard. Even if a handler accidentally returns a User struct 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
}
Enter fullscreen mode Exit fullscreen mode

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)
    })
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

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})
}
Enter fullscreen mode Exit fullscreen mode

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:")
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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,
            )
        })
    }
}
Enter fullscreen mode Exit fullscreen mode

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")
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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/
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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')
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

4. Get all products (paginated):

curl -s "http://localhost:8080/api/v1/products?page=1&limit=10" \
  -H "Authorization: Bearer $TOKEN" | jq
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)