CtrlK
BlogDocsLog inGet started
Tessl Logo

go-chi-project-starter

Scaffold and develop production-ready REST APIs using the Chi v5 router with clean architecture, structured logging (slog), SQLX, and idiomatic Go patterns.

66

Quality

55%

Does it follow best practices?

Impact

Pending

No eval scenarios have been run

SecuritybySnyk

Passed

No known issues

Optimize this skill with Tessl

npx tessl skill review --optimize ./backend-go/go-chi-project-starter/SKILL.md
SKILL.md
Quality
Evals
Security

Go Chi Project Starter

Scaffold and develop production-ready REST APIs using the Chi v5 router with clean architecture, structured logging (slog), SQLX, and idiomatic Go patterns.

Prerequisites

  • Go 1.22+ installed (go version)
  • PostgreSQL for production (SQLite for local dev)
  • golangci-lint for linting (go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest)
  • golang-migrate CLI for database migrations (go install -tags 'postgres' github.com/golang-migrate/migrate/v4/cmd/migrate@latest)
  • swag CLI for Swagger generation (go install github.com/swaggo/swag/cmd/swag@latest)

Scaffold Command

mkdir -p myapp && cd myapp
go mod init github.com/yourorg/myapp
go get github.com/go-chi/chi/v5@latest
go get github.com/go-chi/render@latest
go get github.com/go-chi/cors@latest
go get github.com/jmoiron/sqlx@latest
go get github.com/lib/pq@latest
go get github.com/golang-jwt/jwt/v5@latest
go get github.com/go-playground/validator/v10@latest

Project Structure

myapp/
├── cmd/
│   └── server/
│       └── main.go              # Entrypoint, wires everything
├── internal/
│   ├── config/
│   │   └── config.go            # Env-based config
│   ├── handler/
│   │   ├── user.go              # User HTTP handlers
│   │   ├── auth.go              # Auth handlers
│   │   └── health.go            # Health check
│   ├── middleware/
│   │   ├── auth.go              # JWT auth middleware
│   │   └── logging.go           # Request logging with slog
│   ├── model/
│   │   └── user.go              # Domain models with DB tags
│   ├── repository/
│   │   └── user.go              # SQLX-based data access
│   ├── service/
│   │   └── user.go              # Business logic
│   └── server/
│       └── server.go            # Chi router setup, routes, middleware
├── migrations/
│   ├── 000001_create_users.up.sql
│   └── 000001_create_users.down.sql
├── pkg/
│   └── response/
│       └── response.go          # Shared JSON response helpers
├── docs/                        # Generated by swag init
├── .env.example                   # Environment variable template
├── go.mod
├── go.sum
└── Makefile

Key Conventions

  • Clean architecture: cmd/ for entrypoints, internal/ for application code (unexportable), pkg/ for reusable library code.
  • Chi is a thin router on top of net/http. Handlers are standard http.HandlerFunc. Middleware is standard func(http.Handler) http.Handler.
  • Use chi.URLParam(r, "id") to extract path parameters.
  • Use render.Bind() and render.Render() from go-chi/render for request/response marshalling.
  • Dependency injection via closures: handler constructors return http.HandlerFunc and close over service interfaces.
  • Use log/slog (stdlib, Go 1.21+) for structured logging throughout. No third-party logger needed.
  • SQLX for database access -- provides named queries, struct scanning, and stays close to raw SQL.
  • Subrouters via r.Route() or r.Mount() for modular route registration.
  • Context propagation via r.Context() is native to net/http -- no adapter needed.

Essential Patterns

Main entrypoint with graceful shutdown

// cmd/server/main.go
package main

import (
	"context"
	"log/slog"
	"net/http"
	"os"
	"os/signal"
	"syscall"
	"time"

	"github.com/jmoiron/sqlx"
	_ "github.com/lib/pq"

	"github.com/yourorg/myapp/internal/config"
	"github.com/yourorg/myapp/internal/server"
)

// @title           MyApp API
// @version         1.0
// @description     A sample Chi API server.
// @host            localhost:8080
// @BasePath        /api/v1
func main() {
	cfg := config.Load()

	// Structured logger
	logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
		Level: slog.LevelInfo,
	}))
	slog.SetDefault(logger)

	// Database
	db, err := sqlx.Connect("postgres", cfg.DatabaseURL)
	if err != nil {
		slog.Error("database connection failed", "error", err)
		os.Exit(1)
	}
	defer db.Close()
	db.SetMaxOpenConns(25)
	db.SetMaxIdleConns(5)

	// Router
	router := server.NewRouter(cfg, db, logger)

	srv := &http.Server{
		Addr:         ":" + cfg.Port,
		Handler:      router,
		ReadTimeout:  10 * time.Second,
		WriteTimeout: 10 * time.Second,
		IdleTimeout:  60 * time.Second,
	}

	go func() {
		slog.Info("server starting", "port", cfg.Port)
		if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
			slog.Error("listen failed", "error", err)
			os.Exit(1)
		}
	}()

	quit := make(chan os.Signal, 1)
	signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
	<-quit

	slog.Info("shutting down server...")
	ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
	defer cancel()

	if err := srv.Shutdown(ctx); err != nil {
		slog.Error("forced shutdown", "error", err)
	}
	slog.Info("server exited")
}

Router setup with middleware stack and subrouters

// internal/server/server.go
package server

import (
	"log/slog"
	"net/http"
	"time"

	"github.com/go-chi/chi/v5"
	chiMiddleware "github.com/go-chi/chi/v5/middleware"
	"github.com/go-chi/cors"
	"github.com/jmoiron/sqlx"

	"github.com/yourorg/myapp/internal/config"
	"github.com/yourorg/myapp/internal/handler"
	appMiddleware "github.com/yourorg/myapp/internal/middleware"
	"github.com/yourorg/myapp/internal/repository"
	"github.com/yourorg/myapp/internal/service"
)

func NewRouter(cfg *config.Config, db *sqlx.DB, logger *slog.Logger) http.Handler {
	r := chi.NewRouter()

	// Middleware stack (order matters)
	r.Use(chiMiddleware.RequestID)
	r.Use(chiMiddleware.RealIP)
	r.Use(appMiddleware.StructuredLogger(logger))
	r.Use(chiMiddleware.Recoverer)
	r.Use(chiMiddleware.Timeout(30 * time.Second))
	r.Use(cors.Handler(cors.Options{
		AllowedOrigins:   cfg.AllowedOrigins,
		AllowedMethods:   []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
		AllowedHeaders:   []string{"Accept", "Authorization", "Content-Type"},
		AllowCredentials: true,
		MaxAge:           300,
	}))

	// Wire dependencies
	userRepo := repository.NewUserRepository(db)
	userSvc := service.NewUserService(userRepo)

	// Health check
	r.Get("/healthz", handler.Healthz)

	// API v1
	r.Route("/api/v1", func(r chi.Router) {
		// Public auth routes
		r.Route("/auth", func(r chi.Router) {
			r.Post("/login", handler.Login(userSvc, cfg))
			r.Post("/register", handler.Register(userSvc))
		})

		// Protected routes
		r.Group(func(r chi.Router) {
			r.Use(appMiddleware.AuthRequired(cfg.JWTSecret))

			r.Route("/users", func(r chi.Router) {
				r.Get("/", handler.ListUsers(userSvc))
				r.Post("/", handler.CreateUser(userSvc))

				// Subrouter with {id} param
				r.Route("/{id}", func(r chi.Router) {
					r.Get("/", handler.GetUser(userSvc))
					r.Put("/", handler.UpdateUser(userSvc))
					r.Delete("/", handler.DeleteUser(userSvc))
				})
			})
		})
	})

	return r
}

Dependency injection via closures (handler pattern)

// internal/handler/user.go
package handler

import (
	"encoding/json"
	"errors"
	"log/slog"
	"net/http"
	"strconv"

	"github.com/go-chi/chi/v5"
	"github.com/go-chi/render"
	"github.com/go-playground/validator/v10"

	"github.com/yourorg/myapp/internal/service"
	"github.com/yourorg/myapp/pkg/response"
)

var validate = validator.New()

type CreateUserRequest struct {
	Name  string `json:"name"  validate:"required,min=2,max=100"`
	Email string `json:"email" validate:"required,email"`
	Age   int    `json:"age"   validate:"required,gte=1,lte=150"`
}

type UpdateUserRequest struct {
	Name string `json:"name" validate:"omitempty,min=2,max=100"`
	Age  int    `json:"age"  validate:"omitempty,gte=1,lte=150"`
}

// GetUser returns a handler that gets a user by ID.
// Dependencies are injected via closure -- no global state.
func GetUser(svc service.UserService) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		idStr := chi.URLParam(r, "id")
		id, err := strconv.ParseUint(idStr, 10, 64)
		if err != nil {
			response.Error(w, r, http.StatusBadRequest, "invalid id parameter")
			return
		}

		user, err := svc.GetByID(r.Context(), uint(id))
		if err != nil {
			if errors.Is(err, service.ErrNotFound) {
				response.Error(w, r, http.StatusNotFound, "user not found")
				return
			}
			slog.ErrorContext(r.Context(), "failed to get user", "error", err, "id", id)
			response.Error(w, r, http.StatusInternalServerError, "internal error")
			return
		}

		render.Status(r, http.StatusOK)
		render.JSON(w, r, user)
	}
}

// ListUsers returns a handler that lists users with pagination.
func ListUsers(svc service.UserService) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		page, _ := strconv.Atoi(r.URL.Query().Get("page"))
		pageSize, _ := strconv.Atoi(r.URL.Query().Get("page_size"))

		if page < 1 {
			page = 1
		}
		if pageSize < 1 || pageSize > 100 {
			pageSize = 20
		}

		users, total, err := svc.List(r.Context(), page, pageSize)
		if err != nil {
			slog.ErrorContext(r.Context(), "failed to list users", "error", err)
			response.Error(w, r, http.StatusInternalServerError, "internal error")
			return
		}

		render.JSON(w, r, map[string]interface{}{
			"data":      users,
			"total":     total,
			"page":      page,
			"page_size": pageSize,
		})
	}
}

// CreateUser returns a handler that creates a new user.
func CreateUser(svc service.UserService) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		var req CreateUserRequest
		if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
			response.Error(w, r, http.StatusBadRequest, "invalid request body")
			return
		}
		if err := validate.Struct(&req); err != nil {
			response.ValidationError(w, r, err)
			return
		}

		user, err := svc.Create(r.Context(), req.Name, req.Email, req.Age)
		if err != nil {
			if errors.Is(err, service.ErrDuplicateEmail) {
				response.Error(w, r, http.StatusConflict, "email already registered")
				return
			}
			slog.ErrorContext(r.Context(), "failed to create user", "error", err)
			response.Error(w, r, http.StatusInternalServerError, "internal error")
			return
		}

		render.Status(r, http.StatusCreated)
		render.JSON(w, r, user)
	}
}

// UpdateUser returns a handler that updates a user.
func UpdateUser(svc service.UserService) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		idStr := chi.URLParam(r, "id")
		id, err := strconv.ParseUint(idStr, 10, 64)
		if err != nil {
			response.Error(w, r, http.StatusBadRequest, "invalid id parameter")
			return
		}

		var req UpdateUserRequest
		if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
			response.Error(w, r, http.StatusBadRequest, "invalid request body")
			return
		}
		if err := validate.Struct(&req); err != nil {
			response.ValidationError(w, r, err)
			return
		}

		user, err := svc.Update(r.Context(), uint(id), req.Name, req.Age)
		if err != nil {
			if errors.Is(err, service.ErrNotFound) {
				response.Error(w, r, http.StatusNotFound, "user not found")
				return
			}
			slog.ErrorContext(r.Context(), "failed to update user", "error", err)
			response.Error(w, r, http.StatusInternalServerError, "internal error")
			return
		}

		render.JSON(w, r, user)
	}
}

// DeleteUser returns a handler that deletes a user.
func DeleteUser(svc service.UserService) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		idStr := chi.URLParam(r, "id")
		id, err := strconv.ParseUint(idStr, 10, 64)
		if err != nil {
			response.Error(w, r, http.StatusBadRequest, "invalid id parameter")
			return
		}

		if err := svc.Delete(r.Context(), uint(id)); err != nil {
			if errors.Is(err, service.ErrNotFound) {
				response.Error(w, r, http.StatusNotFound, "user not found")
				return
			}
			slog.ErrorContext(r.Context(), "failed to delete user", "error", err)
			response.Error(w, r, http.StatusInternalServerError, "internal error")
			return
		}

		w.WriteHeader(http.StatusNoContent)
	}
}

// Healthz is a simple health check handler.
func Healthz(w http.ResponseWriter, r *http.Request) {
	render.JSON(w, r, map[string]string{"status": "ok"})
}

Structured logging middleware with slog

// internal/middleware/logging.go
package middleware

import (
	"log/slog"
	"net/http"
	"time"

	chiMiddleware "github.com/go-chi/chi/v5/middleware"
)

func StructuredLogger(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()

			ww := chiMiddleware.NewWrapResponseWriter(w, r.ProtoMajor)
			next.ServeHTTP(ww, r)

			logger.Info("request completed",
				"method", r.Method,
				"path", r.URL.Path,
				"status", ww.Status(),
				"bytes", ww.BytesWritten(),
				"duration_ms", time.Since(start).Milliseconds(),
				"request_id", chiMiddleware.GetReqID(r.Context()),
				"remote_addr", r.RemoteAddr,
			)
		})
	}
}

Auth middleware (standard net/http)

// internal/middleware/auth.go
package middleware

import (
	"context"
	"net/http"
	"strings"

	"github.com/go-chi/render"
	"github.com/golang-jwt/jwt/v5"
)

type contextKey string

const UserIDKey contextKey = "userID"

func AuthRequired(secret string) func(http.Handler) http.Handler {
	return func(next http.Handler) http.Handler {
		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			header := r.Header.Get("Authorization")
			if header == "" {
				render.Status(r, http.StatusUnauthorized)
				render.JSON(w, r, map[string]string{"error": "missing authorization header"})
				return
			}

			parts := strings.SplitN(header, " ", 2)
			if len(parts) != 2 || parts[0] != "Bearer" {
				render.Status(r, http.StatusUnauthorized)
				render.JSON(w, r, map[string]string{"error": "invalid authorization format"})
				return
			}

			token, err := jwt.Parse(parts[1], func(t *jwt.Token) (interface{}, error) {
				if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
					return nil, jwt.ErrSignatureInvalid
				}
				return []byte(secret), nil
			})
			if err != nil || !token.Valid {
				render.Status(r, http.StatusUnauthorized)
				render.JSON(w, r, map[string]string{"error": "invalid token"})
				return
			}

			claims, ok := token.Claims.(jwt.MapClaims)
			if !ok {
				render.Status(r, http.StatusUnauthorized)
				render.JSON(w, r, map[string]string{"error": "invalid claims"})
				return
			}

			// Add user ID to context
			ctx := context.WithValue(r.Context(), UserIDKey, claims["sub"])
			next.ServeHTTP(w, r.WithContext(ctx))
		})
	}
}

// GetUserID extracts the user ID from context. Returns empty string if not found.
func GetUserID(ctx context.Context) string {
	id, _ := ctx.Value(UserIDKey).(string)
	return id
}

Shared JSON response helpers

// pkg/response/response.go
package response

import (
	"net/http"

	"github.com/go-chi/render"
	"github.com/go-playground/validator/v10"
)

type ErrorBody struct {
	Error   string            `json:"error"`
	Details map[string]string `json:"details,omitempty"`
}

func Error(w http.ResponseWriter, r *http.Request, status int, message string) {
	render.Status(r, status)
	render.JSON(w, r, ErrorBody{Error: message})
}

func ValidationError(w http.ResponseWriter, r *http.Request, err error) {
	details := make(map[string]string)
	if ve, ok := err.(validator.ValidationErrors); ok {
		for _, e := range ve {
			details[e.Field()] = e.Tag() + " validation failed"
		}
	}
	render.Status(r, http.StatusBadRequest)
	render.JSON(w, r, ErrorBody{
		Error:   "validation failed",
		Details: details,
	})
}

SQLX repository with named queries

// internal/repository/user.go
package repository

import (
	"context"
	"database/sql"
	"errors"

	"github.com/jmoiron/sqlx"

	"github.com/yourorg/myapp/internal/model"
)

var ErrNotFound = errors.New("record not found")

type UserRepository interface {
	Create(ctx context.Context, user *model.User) error
	GetByID(ctx context.Context, id uint) (*model.User, error)
	GetByEmail(ctx context.Context, email string) (*model.User, error)
	List(ctx context.Context, offset, limit int) ([]model.User, int64, error)
	Update(ctx context.Context, user *model.User) error
	Delete(ctx context.Context, id uint) error
}

type userRepository struct {
	db *sqlx.DB
}

func NewUserRepository(db *sqlx.DB) UserRepository {
	return &userRepository{db: db}
}

func (r *userRepository) Create(ctx context.Context, user *model.User) error {
	query := `INSERT INTO users (name, email, age, created_at, updated_at)
	           VALUES (:name, :email, :age, NOW(), NOW())
	           RETURNING id, created_at, updated_at`

	rows, err := r.db.NamedQueryContext(ctx, query, user)
	if err != nil {
		return err
	}
	defer rows.Close()

	if rows.Next() {
		return rows.StructScan(user)
	}
	return errors.New("insert returned no rows")
}

func (r *userRepository) GetByID(ctx context.Context, id uint) (*model.User, error) {
	var user model.User
	err := r.db.GetContext(ctx, &user, "SELECT * FROM users WHERE id = $1 AND deleted_at IS NULL", id)
	if errors.Is(err, sql.ErrNoRows) {
		return nil, ErrNotFound
	}
	return &user, err
}

func (r *userRepository) GetByEmail(ctx context.Context, email string) (*model.User, error) {
	var user model.User
	err := r.db.GetContext(ctx, &user, "SELECT * FROM users WHERE email = $1 AND deleted_at IS NULL", email)
	if errors.Is(err, sql.ErrNoRows) {
		return nil, ErrNotFound
	}
	return &user, err
}

func (r *userRepository) List(ctx context.Context, offset, limit int) ([]model.User, int64, error) {
	var total int64
	err := r.db.GetContext(ctx, &total, "SELECT COUNT(*) FROM users WHERE deleted_at IS NULL")
	if err != nil {
		return nil, 0, err
	}

	var users []model.User
	err = r.db.SelectContext(ctx, &users,
		"SELECT * FROM users WHERE deleted_at IS NULL ORDER BY id LIMIT $1 OFFSET $2",
		limit, offset,
	)
	return users, total, err
}

func (r *userRepository) Update(ctx context.Context, user *model.User) error {
	query := `UPDATE users SET name = :name, age = :age, updated_at = NOW()
	           WHERE id = :id AND deleted_at IS NULL`
	result, err := r.db.NamedExecContext(ctx, query, user)
	if err != nil {
		return err
	}
	rows, _ := result.RowsAffected()
	if rows == 0 {
		return ErrNotFound
	}
	return nil
}

func (r *userRepository) Delete(ctx context.Context, id uint) error {
	result, err := r.db.ExecContext(ctx,
		"UPDATE users SET deleted_at = NOW() WHERE id = $1 AND deleted_at IS NULL", id)
	if err != nil {
		return err
	}
	rows, _ := result.RowsAffected()
	if rows == 0 {
		return ErrNotFound
	}
	return nil
}

Model with SQLX struct tags

// internal/model/user.go
package model

import "time"

type User struct {
	ID        uint       `db:"id"         json:"id"`
	Name      string     `db:"name"       json:"name"`
	Email     string     `db:"email"      json:"email"`
	Age       int        `db:"age"        json:"age"`
	CreatedAt time.Time  `db:"created_at" json:"created_at"`
	UpdatedAt time.Time  `db:"updated_at" json:"updated_at"`
	DeletedAt *time.Time `db:"deleted_at" json:"-"`
}

SQL migration

-- migrations/000001_create_users.up.sql
CREATE TABLE IF NOT EXISTS users (
    id         BIGSERIAL PRIMARY KEY,
    name       VARCHAR(100)  NOT NULL,
    email      VARCHAR(255)  NOT NULL UNIQUE,
    age        INT           NOT NULL CHECK (age > 0),
    created_at TIMESTAMPTZ   NOT NULL DEFAULT NOW(),
    updated_at TIMESTAMPTZ   NOT NULL DEFAULT NOW(),
    deleted_at TIMESTAMPTZ
);

CREATE INDEX idx_users_email ON users (email) WHERE deleted_at IS NULL;
-- migrations/000001_create_users.down.sql
DROP TABLE IF EXISTS users;

First Steps After Scaffold

  1. Copy .env.example to .env and fill in values
  2. Install dependencies: go mod download
  3. Run database migrations: migrate -path migrations -database "$DATABASE_URL" up
  4. Start dev server: go run ./cmd/server
  5. Test the API: curl http://localhost:8080/healthz

Common Commands

# Run the server
go run ./cmd/server

# Run tests
go test ./... -v -race -count=1

# Run tests with coverage
go test ./... -coverprofile=coverage.out && go tool cover -html=coverage.out

# Generate Swagger docs
swag init -g cmd/server/main.go -o docs

# Lint
golangci-lint run ./...

# Build binary
go build -o bin/server ./cmd/server

# Tidy dependencies
go mod tidy

# Format code
gofmt -w .

# Database migrations
migrate -path migrations -database "$DATABASE_URL" up
migrate -path migrations -database "$DATABASE_URL" down 1
migrate create -ext sql -dir migrations -seq create_orders

Integration Notes

  • Chi + net/http: Chi handlers are standard http.HandlerFunc. Any net/http-compatible middleware works out of the box (unlike Fiber). This makes it easy to integrate third-party middleware.
  • Why closures over structs: The closure pattern (func GetUser(svc Service) http.HandlerFunc) keeps handlers as plain functions, avoids struct boilerplate, and makes dependency injection explicit at the route level. Use handler structs if you have 5+ dependencies per group.
  • SQLX over GORM: SQLX gives you full SQL control with struct scanning convenience. Use it when you want predictable queries and no magic. GORM is appropriate if you want rapid prototyping with auto-migrations.
  • Testing: Use httptest.NewServer(router) for integration tests. Use httptest.NewRecorder() with chi.NewRouter() for handler tests. Mock repository interfaces for service-level tests.
  • Test database: Use testcontainers-go (github.com/testcontainers/testcontainers-go) to spin up a disposable PostgreSQL container for integration tests. Alternatively, define a docker-compose.test.yml with a dedicated test DB and run migrations before the test suite. Either approach avoids polluting your development database and ensures reproducible test runs.
  • Docker: Multi-stage build with golang:1.22-alpine for build, alpine:latest for runtime. Copy the binary and migrations folder.
  • Observability: The slog middleware logs every request with method, path, status, duration, and request ID. Add slog.With("service", "myapp") for service-level tagging. Integrate with OpenTelemetry via slog handler wrappers.
  • Subrouter mounting: Use r.Mount("/admin", adminRouter()) to compose independently-defined routers. This is useful for modular monoliths where each domain owns its routes.
Repository
achreftlili/deep-dev-skills
Last updated
Created

Is this your skill?

If you maintain this skill, you can claim it as your own. Once claimed, you can manage eval scenarios, bundle related skills, attach documentation or rules, and ensure cross-agent compatibility.