Scaffold and develop production-ready REST APIs using the Chi v5 router with clean architecture, structured logging (slog), SQLX, and idiomatic Go patterns.
66
55%
Does it follow best practices?
Impact
Pending
No eval scenarios have been run
Passed
No known issues
Optimize this skill with Tessl
npx tessl skill review --optimize ./backend-go/go-chi-project-starter/SKILL.mdScaffold 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@latestmyapp/
├── 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
└── Makefilecmd/ 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_ordershttp.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.181fcbc
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.