CtrlK
BlogDocsLog inGet started
Tessl Logo

gin-project-starter

Scaffold and develop production-ready REST APIs using the Gin web framework with GORM, Swagger, 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/gin-project-starter/SKILL.md
SKILL.md
Quality
Evals
Security

Gin Project Starter

Scaffold and develop production-ready REST APIs using the Gin web framework with GORM, Swagger, and idiomatic Go patterns.

Prerequisites

  • Go 1.22+ installed (go version)
  • swag CLI for Swagger generation (go install github.com/swaggo/swag/cmd/swag@latest)
  • PostgreSQL or MySQL (for GORM; SQLite works for local dev)
  • golangci-lint for linting (go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest)

Scaffold Command

mkdir -p myapp && cd myapp
go mod init github.com/yourorg/myapp
go get github.com/gin-gonic/gin@v1.10.0
go get gorm.io/gorm@latest
go get gorm.io/driver/postgres@latest
go get github.com/swaggo/gin-swagger@latest
go get github.com/swaggo/files@latest
go get github.com/gin-contrib/cors@latest

Project Structure

myapp/
├── cmd/
│   └── server/
│       └── main.go              # Entrypoint, wires everything together
├── internal/
│   ├── config/
│   │   └── config.go            # Env-based config loading
│   ├── handler/
│   │   ├── user.go              # HTTP handlers grouped by domain
│   │   └── health.go
│   ├── middleware/
│   │   ├── auth.go              # JWT/bearer token auth middleware
│   │   └── ratelimit.go
│   ├── model/
│   │   └── user.go              # GORM models
│   ├── repository/
│   │   └── user.go              # DB access layer
│   ├── service/
│   │   └── user.go              # Business logic
│   └── router/
│       └── router.go            # Route definitions and middleware registration
├── docs/                        # Generated by swag init
├── .env.example                   # Environment variable template
├── go.mod
├── go.sum
└── Makefile

Key Conventions

  • Use internal/ to prevent external imports of application internals.
  • One handler file per domain entity (e.g., user.go, order.go).
  • Handlers accept interfaces (services), services accept interfaces (repositories) -- dependency inversion.
  • Never call gorm.DB directly in handlers. Always go through repository and service layers.
  • Use context.Context propagation from c.Request.Context() through service and repository calls.
  • Return structured JSON error responses consistently via a shared error helper.
  • Use struct tags for binding and validation: binding:"required", binding:"email".
  • Environment config via os.Getenv or a config library -- never hardcode credentials.

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/yourorg/myapp/internal/config"
	"github.com/yourorg/myapp/internal/router"
)

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

	r := router.Setup(cfg)

	srv := &http.Server{
		Addr:         ":" + cfg.Port,
		Handler:      r,
		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(), 5*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 and route groups

// internal/router/router.go
package router

import (
	"github.com/gin-contrib/cors"
	"github.com/gin-gonic/gin"
	swaggerFiles "github.com/swaggo/files"
	ginSwagger "github.com/swaggo/gin-swagger"

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

func Setup(cfg *config.Config, db *gorm.DB) *gin.Engine {
	if cfg.Env == "production" {
		gin.SetMode(gin.ReleaseMode)
	}

	r := gin.New()

	// Global middleware
	r.Use(gin.Recovery())
	r.Use(gin.Logger())
	r.Use(cors.New(cors.Config{
		AllowOrigins:     cfg.AllowedOrigins,
		AllowMethods:     []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
		AllowHeaders:     []string{"Origin", "Content-Type", "Authorization"},
		AllowCredentials: true,
	}))

	// Swagger
	r.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler))

	// Health check (no auth)
	r.GET("/healthz", handler.Healthz)

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

	// API v1 group
	v1 := r.Group("/api/v1")
	{
		// Public routes
		auth := v1.Group("/auth")
		{
			auth.POST("/register", userHandler.Register)
		}

		// Protected routes
		protected := v1.Group("/")
		protected.Use(middleware.AuthRequired(cfg.JWTSecret))
		{
			users := protected.Group("/users")
			{
				users.GET("", userHandler.ListUsers)
				users.GET("/:id", userHandler.GetUser)
				users.PUT("/:id", userHandler.UpdateUser)
				users.DELETE("/:id", userHandler.DeleteUser)
			}
		}
	}

	return r
}

Handler with request binding and validation

// internal/handler/user.go
package handler

import (
	"errors"
	"net/http"
	"strconv"

	"github.com/gin-gonic/gin"
	"github.com/yourorg/myapp/internal/service"
)

// UserHandler groups all user-related HTTP handlers.
// Dependencies are injected via the constructor.
type UserHandler struct {
	svc service.UserService
}

func NewUserHandler(svc service.UserService) *UserHandler {
	return &UserHandler{svc: svc}
}

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

type UserResponse struct {
	ID    uint   `json:"id"`
	Name  string `json:"name"`
	Email string `json:"email"`
}

// Register godoc
// @Summary      Register a new user
// @Tags         auth
// @Accept       json
// @Produce      json
// @Param        body  body  CreateUserRequest  true  "User data"
// @Success      201  {object}  UserResponse
// @Failure      400  {object}  ErrorResponse
// @Router       /auth/register [post]
func (h *UserHandler) Register(c *gin.Context) {
	var req CreateUserRequest
	if err := c.ShouldBindJSON(&req); err != nil {
		c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
		return
	}

	ctx := c.Request.Context()
	user, err := h.svc.Create(ctx, req.Name, req.Email, req.Age)
	if err != nil {
		if errors.Is(err, service.ErrDuplicateEmail) {
			c.JSON(http.StatusConflict, ErrorResponse{Error: "email already registered"})
			return
		}
		c.JSON(http.StatusInternalServerError, ErrorResponse{Error: "internal error"})
		return
	}

	c.JSON(http.StatusCreated, UserResponse{
		ID:    user.ID,
		Name:  user.Name,
		Email: user.Email,
	})
}

// GetUser godoc
// @Summary      Get user by ID
// @Tags         users
// @Produce      json
// @Param        id   path  int  true  "User ID"
// @Success      200  {object}  UserResponse
// @Failure      404  {object}  ErrorResponse
// @Router       /users/{id} [get]
func (h *UserHandler) GetUser(c *gin.Context) {
	id, err := strconv.ParseUint(c.Param("id"), 10, 64)
	if err != nil {
		c.JSON(http.StatusBadRequest, ErrorResponse{Error: "invalid id"})
		return
	}

	ctx := c.Request.Context()
	user, err := h.svc.GetByID(ctx, uint(id))
	if err != nil {
		if errors.Is(err, service.ErrNotFound) {
			c.JSON(http.StatusNotFound, ErrorResponse{Error: "user not found"})
			return
		}
		c.JSON(http.StatusInternalServerError, ErrorResponse{Error: "internal error"})
		return
	}

	c.JSON(http.StatusOK, UserResponse{
		ID:    user.ID,
		Name:  user.Name,
		Email: user.Email,
	})
}

// ListUsers with query params for pagination
func (h *UserHandler) ListUsers(c *gin.Context) {
	page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
	pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))

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

	ctx := c.Request.Context()
	users, total, err := h.svc.List(ctx, page, pageSize)
	if err != nil {
		c.JSON(http.StatusInternalServerError, ErrorResponse{Error: "internal error"})
		return
	}

	c.JSON(http.StatusOK, gin.H{
		"data":      users,
		"total":     total,
		"page":      page,
		"page_size": pageSize,
	})
}

type ErrorResponse struct {
	Error string `json:"error"`
}

Auth middleware

// internal/middleware/auth.go
package middleware

import (
	"net/http"
	"strings"

	"github.com/gin-gonic/gin"
	"github.com/golang-jwt/jwt/v5"
)

func AuthRequired(secret string) gin.HandlerFunc {
	return func(c *gin.Context) {
		header := c.GetHeader("Authorization")
		if header == "" {
			c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "missing authorization header"})
			return
		}

		parts := strings.SplitN(header, " ", 2)
		if len(parts) != 2 || parts[0] != "Bearer" {
			c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"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 {
			c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid token"})
			return
		}

		claims, ok := token.Claims.(jwt.MapClaims)
		if !ok {
			c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid claims"})
			return
		}

		c.Set("userID", claims["sub"])
		c.Next()
	}
}

GORM model and repository

// internal/model/user.go
package model

import "gorm.io/gorm"

type User struct {
	gorm.Model
	Name  string `gorm:"type:varchar(100);not null"`
	Email string `gorm:"type:varchar(255);uniqueIndex;not null"`
	Age   int    `gorm:"not null"`
}
// internal/repository/user.go
package repository

import (
	"context"
	"errors"

	"gorm.io/gorm"

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

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)
}

type userRepository struct {
	db *gorm.DB
}

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

func (r *userRepository) Create(ctx context.Context, user *model.User) error {
	return r.db.WithContext(ctx).Create(user).Error
}

func (r *userRepository) GetByID(ctx context.Context, id uint) (*model.User, error) {
	var user model.User
	err := r.db.WithContext(ctx).First(&user, id).Error
	if errors.Is(err, gorm.ErrRecordNotFound) {
		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.WithContext(ctx).Where("email = ?", email).First(&user).Error
	if errors.Is(err, gorm.ErrRecordNotFound) {
		return nil, ErrNotFound
	}
	return &user, err
}

func (r *userRepository) List(ctx context.Context, offset, limit int) ([]model.User, int64, error) {
	var users []model.User
	var total int64

	if err := r.db.WithContext(ctx).Model(&model.User{}).Count(&total).Error; err != nil {
		return nil, 0, err
	}

	err := r.db.WithContext(ctx).Offset(offset).Limit(limit).Find(&users).Error
	return users, total, err
}

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

Config loading

// internal/config/config.go
package config

import "os"

type Config struct {
	Port           string
	Env            string
	DatabaseURL    string
	JWTSecret      string
	AllowedOrigins []string
}

func Load() *Config {
	return &Config{
		Port:           getEnv("PORT", "8080"),
		Env:            getEnv("APP_ENV", "development"),
		DatabaseURL:    getEnv("DATABASE_URL", "postgres://localhost:5432/myapp?sslmode=disable"),
		JWTSecret:      getEnv("JWT_SECRET", "change-me-in-production"),
		AllowedOrigins: []string{getEnv("CORS_ORIGIN", "http://localhost:3000")},
	}
}

func getEnv(key, fallback string) string {
	if v := os.Getenv(key); v != "" {
		return v
	}
	return fallback
}

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 (run from project root)
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 .

Integration Notes

  • Database migrations: Pair with golang-migrate or use GORM's AutoMigrate for dev only. For production, use versioned migrations via github.com/golang-migrate/migrate/v4.
  • Docker: Use a multi-stage Dockerfile -- golang:1.22-alpine for build, alpine:latest for runtime. Copy only the binary.
  • Observability: Add prometheus/client_golang for metrics, gin.Logger() writes to stdout which pairs with structured log collectors.
  • Testing: Use httptest.NewRecorder() with gin.CreateTestContext() for handler tests. Mock repository interfaces for service tests.
  • CI: Run golangci-lint run, go test -race ./..., and go vet ./... in CI pipeline.
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.