backend-go/go-chi-project-starter/SKILL.md
Scaffold and develop production-ready REST APIs using the Chi v5 router with clean architecture, structured logging (slog), SQLX, and idiomatic Go patterns.
npx skillsauth add achreftlili/deep-dev-skills go-chi-project-starterInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
Scaffold and develop production-ready REST APIs using the Chi v5 router with clean architecture, structured logging (slog), SQLX, and idiomatic Go patterns.
go version)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)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
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
cmd/ for entrypoints, internal/ for application code (unexportable), pkg/ for reusable library code.net/http. Handlers are standard http.HandlerFunc. Middleware is standard func(http.Handler) http.Handler.chi.URLParam(r, "id") to extract path parameters.render.Bind() and render.Render() from go-chi/render for request/response marshalling.http.HandlerFunc and close over service interfaces.log/slog (stdlib, Go 1.21+) for structured logging throughout. No third-party logger needed.r.Route() or r.Mount() for modular route registration.r.Context() is native to net/http -- no adapter needed.// 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")
}
// 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
}
// 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"})
}
// 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,
)
})
}
}
// 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
}
// 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,
})
}
// 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
}
// 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:"-"`
}
-- 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;
.env.example to .env and fill in valuesgo mod downloadmigrate -path migrations -database "$DATABASE_URL" upgo run ./cmd/servercurl http://localhost:8080/healthz# 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
http.HandlerFunc. Any net/http-compatible middleware works out of the box (unlike Fiber). This makes it easy to integrate third-party middleware.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.httptest.NewServer(router) for integration tests. Use httptest.NewRecorder() with chi.NewRouter() for handler tests. Mock repository interfaces for service-level tests.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.golang:1.22-alpine for build, alpine:latest for runtime. Copy the binary and migrations folder.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.r.Mount("/admin", adminRouter()) to compose independently-defined routers. This is useful for modular monoliths where each domain owns its routes.testing
Set up Vitest 2.x with TypeScript for unit and component testing using test/describe/it, vi.fn/vi.mock/vi.spyOn, component testing with Testing Library, coverage (v8/istanbul), workspace config, and snapshot testing.
testing
Set up pytest 8.x with Python for unit and integration testing using fixtures (scope, autouse, parametrize), async tests (pytest-asyncio), mocking (unittest.mock, pytest-mock), coverage (pytest-cov), conftest.py patterns, and markers.
testing
Set up Playwright 1.49+ with TypeScript for E2E testing using page object model, fixtures, test.describe/test blocks, assertions, selectors, network mocking, CI configuration, and trace viewer.
testing
Set up Jest 30+ with TypeScript for unit tests, integration tests, mocking (jest.fn, jest.mock, jest.spyOn), coverage configuration, custom matchers, snapshot testing, and setup/teardown patterns.