CtrlK
BlogDocsLog inGet started
Tessl Logo

echo-project-starter

Scaffold and develop production-ready REST APIs using the Echo v4 web framework with custom validation, JWT auth, Swagger, and idiomatic Go patterns.

77

Quality

72%

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/echo-project-starter/SKILL.md
SKILL.md
Quality
Evals
Security

Echo Project Starter

Scaffold and develop production-ready REST APIs using the Echo v4 web framework with custom validation, JWT auth, 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 production (SQLite 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/labstack/echo/v4@latest
go get github.com/labstack/echo-jwt/v4@latest
go get github.com/go-playground/validator/v10@latest
go get github.com/swaggo/echo-swagger@latest
go get github.com/swaggo/swag@latest
go get gorm.io/gorm@latest
go get gorm.io/driver/postgres@latest

Project Structure

myapp/
├── cmd/
│   └── server/
│       └── main.go              # Entrypoint, wires dependencies
├── internal/
│   ├── config/
│   │   └── config.go            # Env-based configuration
│   ├── handler/
│   │   ├── user.go              # User HTTP handlers
│   │   ├── auth.go              # Auth HTTP handlers
│   │   └── health.go            # Health check endpoint
│   ├── middleware/
│   │   └── auth.go              # JWT middleware config
│   ├── model/
│   │   └── user.go              # Domain/DB models
│   ├── repository/
│   │   └── user.go              # Data access layer
│   ├── service/
│   │   └── user.go              # Business logic
│   ├── validator/
│   │   └── validator.go         # Custom validator registration
│   └── server/
│       └── server.go            # Echo instance setup, routes, middleware
├── docs/                        # Generated by swag init
├── .env.example                   # Environment variable template
├── go.mod
├── go.sum
└── Makefile

Key Conventions

  • Use internal/ to keep application code unexportable.
  • One handler struct per domain entity. Handlers receive service interfaces via constructor injection.
  • Always propagate context.Context via c.Request().Context() through service and repository layers.
  • Register a custom validator on the Echo instance so c.Validate() uses go-playground/validator.
  • Use Echo's HTTPErrorHandler for centralized error responses -- do not scatter error formatting across handlers.
  • Bind requests with c.Bind() (auto-detects JSON, form, query), then validate with c.Validate().
  • Group routes with e.Group() and apply middleware per group.
  • Return errors from handlers (return echo.NewHTTPError(...)) instead of writing responses and returning nil.

Essential Patterns

Main entrypoint with graceful shutdown

// cmd/server/main.go
package main

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

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

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

	srv := server.New(cfg)

	go func() {
		slog.Info("server starting", "port", cfg.Port)
		if err := srv.Start(":" + cfg.Port); err != nil {
			slog.Info("server stopped", "reason", err)
		}
	}()

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

Custom validator

// internal/validator/validator.go
package validator

import (
	"net/http"

	"github.com/go-playground/validator/v10"
	"github.com/labstack/echo/v4"
)

// CustomValidator wraps go-playground/validator for Echo.
type CustomValidator struct {
	validator *validator.Validate
}

func New() *CustomValidator {
	return &CustomValidator{validator: validator.New()}
}

func (cv *CustomValidator) Validate(i interface{}) error {
	if err := cv.validator.Struct(i); err != nil {
		return echo.NewHTTPError(http.StatusBadRequest, formatValidationErrors(err))
	}
	return nil
}

func formatValidationErrors(err error) map[string]string {
	errs := make(map[string]string)
	for _, e := range err.(validator.ValidationErrors) {
		errs[e.Field()] = e.Tag() + " validation failed"
	}
	return errs
}

Server setup with middleware and routes

// internal/server/server.go
package server

import (
	"net/http"

	"github.com/labstack/echo/v4"
	"github.com/labstack/echo/v4/middleware"
	echoSwagger "github.com/swaggo/echo-swagger"

	"gorm.io/gorm"

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

func New(cfg *config.Config, db *gorm.DB) *echo.Echo {
	e := echo.New()
	e.HideBanner = true

	// Custom validator
	e.Validator = appValidator.New()

	// Custom error handler
	e.HTTPErrorHandler = customErrorHandler

	// Global middleware
	e.Use(middleware.Recover())
	e.Use(middleware.RequestID())
	e.Use(middleware.Logger())
	e.Use(middleware.CORSWithConfig(middleware.CORSConfig{
		AllowOrigins: cfg.AllowedOrigins,
		AllowMethods: []string{http.MethodGet, http.MethodPost, http.MethodPut, http.MethodDelete},
		AllowHeaders: []string{echo.HeaderOrigin, echo.HeaderContentType, echo.HeaderAuthorization},
	}))

	// Swagger
	e.GET("/swagger/*", echoSwagger.WrapHandler)

	// Health
	e.GET("/healthz", func(c echo.Context) error {
		return c.JSON(http.StatusOK, map[string]string{"status": "ok"})
	})

	// API v1
	v1 := e.Group("/api/v1")

	// Public routes
	authHandler := handler.NewAuthHandler(cfg)
	v1.POST("/auth/login", authHandler.Login)
	v1.POST("/auth/register", authHandler.Register)

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

	// Protected routes
	protected := v1.Group("")
	protected.Use(appMiddleware.JWTMiddleware(cfg.JWTSecret))

	userHandler := handler.NewUserHandler(userSvc)
	protected.GET("/users", userHandler.List)
	protected.GET("/users/:id", userHandler.GetByID)
	protected.PUT("/users/:id", userHandler.Update)
	protected.DELETE("/users/:id", userHandler.Delete)

	return e
}

func customErrorHandler(err error, c echo.Context) {
	if c.Response().Committed {
		return
	}

	he, ok := err.(*echo.HTTPError)
	if !ok {
		he = echo.NewHTTPError(http.StatusInternalServerError, "internal server error")
	}

	c.JSON(he.Code, map[string]interface{}{
		"error": he.Message,
	})
}

JWT middleware configuration

// internal/middleware/auth.go
package middleware

import (
	echojwt "github.com/labstack/echo-jwt/v4"
	"github.com/labstack/echo/v4"
	"github.com/golang-jwt/jwt/v5"
)

func JWTMiddleware(secret string) echo.MiddlewareFunc {
	return echojwt.WithConfig(echojwt.Config{
		SigningKey: []byte(secret),
		NewClaimsFunc: func(c echo.Context) jwt.Claims {
			return new(jwt.RegisteredClaims)
		},
	})
}

Handler with request binding and validation

// internal/handler/user.go
package handler

import (
	"net/http"
	"strconv"

	"github.com/labstack/echo/v4"

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

type UserHandler struct {
	svc service.UserService
}

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

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"`
}

// GetByID godoc
// @Summary      Get user by ID
// @Tags         users
// @Produce      json
// @Param        id   path  int  true  "User ID"
// @Success      200  {object}  model.User
// @Failure      404  {object}  map[string]string
// @Router       /users/{id} [get]
func (h *UserHandler) GetByID(c echo.Context) error {
	id, err := strconv.ParseUint(c.Param("id"), 10, 64)
	if err != nil {
		return echo.NewHTTPError(http.StatusBadRequest, "invalid id parameter")
	}

	ctx := c.Request().Context()
	user, err := h.svc.GetByID(ctx, uint(id))
	if err != nil {
		return echo.NewHTTPError(http.StatusNotFound, "user not found")
	}

	return c.JSON(http.StatusOK, user)
}

// List godoc
// @Summary      List users with pagination
// @Tags         users
// @Produce      json
// @Param        page       query  int  false  "Page number"  default(1)
// @Param        page_size  query  int  false  "Page size"    default(20)
// @Success      200  {object}  map[string]interface{}
// @Router       /users [get]
func (h *UserHandler) List(c echo.Context) error {
	type PaginationQuery struct {
		Page     int `query:"page"      validate:"omitempty,gte=1"`
		PageSize int `query:"page_size" validate:"omitempty,gte=1,lte=100"`
	}

	q := PaginationQuery{Page: 1, PageSize: 20}
	if err := c.Bind(&q); err != nil {
		return echo.NewHTTPError(http.StatusBadRequest, "invalid query parameters")
	}
	if err := c.Validate(&q); err != nil {
		return err
	}

	ctx := c.Request().Context()
	users, total, err := h.svc.List(ctx, q.Page, q.PageSize)
	if err != nil {
		return echo.NewHTTPError(http.StatusInternalServerError, "failed to list users")
	}

	return c.JSON(http.StatusOK, map[string]interface{}{
		"data":      users,
		"total":     total,
		"page":      q.Page,
		"page_size": q.PageSize,
	})
}

func (h *UserHandler) Update(c echo.Context) error {
	id, err := strconv.ParseUint(c.Param("id"), 10, 64)
	if err != nil {
		return echo.NewHTTPError(http.StatusBadRequest, "invalid id parameter")
	}

	var req UpdateUserRequest
	if err := c.Bind(&req); err != nil {
		return echo.NewHTTPError(http.StatusBadRequest, "invalid request body")
	}
	if err := c.Validate(&req); err != nil {
		return err
	}

	ctx := c.Request().Context()
	user, err := h.svc.Update(ctx, uint(id), req.Name, req.Age)
	if err != nil {
		return echo.NewHTTPError(http.StatusInternalServerError, "failed to update user")
	}

	return c.JSON(http.StatusOK, user)
}

func (h *UserHandler) Delete(c echo.Context) error {
	id, err := strconv.ParseUint(c.Param("id"), 10, 64)
	if err != nil {
		return echo.NewHTTPError(http.StatusBadRequest, "invalid id parameter")
	}

	ctx := c.Request().Context()
	if err := h.svc.Delete(ctx, uint(id)); err != nil {
		return echo.NewHTTPError(http.StatusInternalServerError, "failed to delete user")
	}

	return c.NoContent(http.StatusNoContent)
}

Service layer with interface

// internal/service/user.go
package service

import (
	"context"
	"errors"

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

var (
	ErrNotFound       = errors.New("not found")
	ErrDuplicateEmail = errors.New("duplicate email")
)

type UserService interface {
	Create(ctx context.Context, name, email string, age int) (*model.User, error)
	GetByID(ctx context.Context, id uint) (*model.User, error)
	List(ctx context.Context, page, pageSize int) ([]model.User, int64, error)
	Update(ctx context.Context, id uint, name string, age int) (*model.User, error)
	Delete(ctx context.Context, id uint) error
}

type userService struct {
	repo repository.UserRepository
}

func NewUserService(repo repository.UserRepository) UserService {
	return &userService{repo: repo}
}

func (s *userService) Create(ctx context.Context, name, email string, age int) (*model.User, error) {
	existing, _ := s.repo.GetByEmail(ctx, email)
	if existing != nil {
		return nil, ErrDuplicateEmail
	}

	user := &model.User{Name: name, Email: email, Age: age}
	if err := s.repo.Create(ctx, user); err != nil {
		return nil, err
	}
	return user, nil
}

func (s *userService) GetByID(ctx context.Context, id uint) (*model.User, error) {
	return s.repo.GetByID(ctx, id)
}

func (s *userService) List(ctx context.Context, page, pageSize int) ([]model.User, int64, error) {
	offset := (page - 1) * pageSize
	return s.repo.List(ctx, offset, pageSize)
}

func (s *userService) Update(ctx context.Context, id uint, name string, age int) (*model.User, error) {
	user, err := s.repo.GetByID(ctx, id)
	if err != nil {
		return nil, err
	}
	if name != "" {
		user.Name = name
	}
	if age > 0 {
		user.Age = age
	}
	if err := s.repo.Update(ctx, user); err != nil {
		return nil, err
	}
	return user, nil
}

func (s *userService) Delete(ctx context.Context, id uint) error {
	return s.repo.Delete(ctx, id)
}

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 .

Integration Notes

  • Echo vs Gin: Echo returns errors from handlers instead of writing responses directly. This enables the centralized HTTPErrorHandler -- lean into this pattern.
  • Testing: Use echo.New() + httptest.NewRequest + httptest.NewRecorder for handler tests. Set e.Validator in test setup. Mock service interfaces.
  • Websockets: Echo has built-in websocket support via golang.org/x/net/websocket. Use echo.WrapHandler for gorilla/websocket if needed.
  • Docker: Multi-stage build -- golang:1.22-alpine for build, alpine:latest for runtime.
  • Observability: Use echo-contrib for Prometheus metrics middleware. Echo's middleware.Logger() emits structured JSON when configured.
  • File uploads: Use c.FormFile() for single files, c.MultipartForm() for multiple. Set e.MaxBodySize to limit upload size.
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.