golang-gin-clean-arch

Clean Architecture for Go Gin APIs. Covers layer separation (entity, usecase, repository, delivery), dependency injection, repository pattern, error handling, input sanitization, and testable project structure. Use when organizing Go/Gin projects, implementing clean/hexagonal architecture, setting up dependency injection, or structuring code for testability.

Safety Notice

This listing is imported from skills.sh public index metadata. Review upstream SKILL.md and repository scripts before running.

Copy this and send it to your AI assistant to learn

Install skill "golang-gin-clean-arch" with this command: npx skills add henriqueatila/golang-gin-clean-arch/henriqueatila-golang-gin-clean-arch-golang-gin-clean-arch

golang-gin-clean-arch — Clean Architecture for Go/Gin

Build Go/Gin APIs with strict layer separation, dependency injection, and testable structure. Covers the 80% of clean architecture patterns you need daily. Works standalone; enhanced with golang-gin-best-practices for Gin-specific patterns.

When to Use

  • Starting or refactoring a Go/Gin project with clean architecture
  • Setting up dependency injection without frameworks
  • Separating business logic from framework code for testability

6 Golden Rules

Non-negotiable. Every code generation must follow them.

  1. Gin is a detail — isolate it. gin and *gin.Context ONLY in internal/delivery/http. Swap Gin for Fiber/CLI — zero business logic changes.
  2. Database is a detail. No SQL/GORM/SQLC in Entities or UseCases. UseCases call repository interfaces only.
  3. Dependency Rule. Source depends inward only. Delivery→UseCases→Entities. Entities know nothing outside.
  4. Separate Request/Response from Domain. Never pass Gin request structs or DB models to UseCases. Map to plain DTOs.
  5. main.go is the only dirty component. DI wiring exclusively in cmd/api/main.go — the only file that knows the entire system.
  6. Export interfaces, hide implementations. Concrete structs are unexported (productUsecase, postgresProductRepo). Constructors return the domain interface. This prevents direct instantiation and enforces the dependency rule at the compiler level.

Project Structure

myapp/
├── cmd/api/main.go                    # Entry point — DI wiring
├── internal/
│   ├── domain/                        # Entities, interfaces, errors (innermost)
│   ├── usecase/                       # Business logic (depends on domain only)
│   ├── repository/                    # Data access — SQLC or GORM
│   └── delivery/http/                 # Gin handlers + routes (outermost)
├── config/                            # Configuration (env)
├── migrations/                        # SQL migration files
└── go.mod

The 4 Layers

Delivery (outermost) → Repository → Usecase → Domain (innermost)

LayerPackageCan ImportNever Imports
Domaininternal/domainstdlib onlyusecase, repository, delivery
Usecaseinternal/usecasedomainrepository, delivery, gin
Repositoryinternal/repositorydomaindelivery, gin
Deliveryinternal/delivery/httpdomainrepository, usecase (concrete)

Layer 1: Domain (Entities + Interfaces)

Pure Go types. No framework dependencies. Defines what the system does, not how.

// internal/domain/product.go
package domain

import (
    "context"
    "time"

    "github.com/google/uuid"
)

type Product struct {
    ID          uuid.UUID
    Name        string
    Description string
    Price       int64 // cents
    Stock       int32
    CreatedAt   time.Time
    UpdatedAt   time.Time
}

type ProductFilter struct {
    Name     string
    MinPrice int64; MaxPrice int64
    Page     int;   Limit    int
}

type ProductRepository interface {
    FindByID(ctx context.Context, id uuid.UUID) (*Product, error)
    FindAll(ctx context.Context, filter ProductFilter) ([]Product, error)
    Create(ctx context.Context, p *Product) error
    Update(ctx context.Context, p *Product) error
    Delete(ctx context.Context, id uuid.UUID) error
}

type ProductUsecase interface {
    GetProduct(ctx context.Context, id uuid.UUID) (*Product, error)
    ListProducts(ctx context.Context, filter ProductFilter) ([]Product, error)
    CreateProduct(ctx context.Context, input CreateProductInput) (*Product, error)
    UpdateProduct(ctx context.Context, id uuid.UUID, input UpdateProductInput) (*Product, error)
    DeleteProduct(ctx context.Context, id uuid.UUID) error
}

type CreateProductInput struct {
    Name        string
    Description string
    Price       int64
    Stock       int32
}
type UpdateProductInput struct {
    Name *string; Description *string
    Price *int64; Stock       *int32
}

Domain errors — see Error Flow below.

Layer 2: Usecase (Business Logic)

Orchestrates domain operations. Depends only on domain interfaces.

// internal/usecase/product_usecase.go
package usecase

import (
    "context"
    "fmt"
    "time"

    "github.com/google/uuid"
    "myapp/internal/domain"
)

type productUsecase struct {
    repo domain.ProductRepository
}

func NewProductUsecase(repo domain.ProductRepository) domain.ProductUsecase {
    return &productUsecase{repo: repo}
}

func (u *productUsecase) CreateProduct(ctx context.Context, input domain.CreateProductInput) (*domain.Product, error) {
    if input.Price <= 0 {
        return nil, fmt.Errorf("create product: %w", domain.ErrValidation)
    }

    product := &domain.Product{
        ID:          uuid.New(),
        Name:        input.Name,
        Description: input.Description,
        Price:       input.Price,
        Stock:       input.Stock,
        CreatedAt:   time.Now().UTC(),
        UpdatedAt:   time.Now().UTC(),
    }

    if err := u.repo.Create(ctx, product); err != nil {
        return nil, fmt.Errorf("create product: %w", err)
    }

    return product, nil
}

func (u *productUsecase) GetProduct(ctx context.Context, id uuid.UUID) (*domain.Product, error) {
    product, err := u.repo.FindByID(ctx, id)
    if err != nil {
        return nil, fmt.Errorf("get product %s: %w", id, err)
    }
    return product, nil
}

// ListProducts, UpdateProduct, DeleteProduct follow the same pattern.

Key: Constructor returns interface (domain.ProductUsecase), hides struct. Usecase never imports gin, sql, or gorm.

Layer 3: Repository (Data Access)

Implements domain interfaces with concrete database technology.

// internal/repository/product_repository.go
package repository

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

    "github.com/google/uuid"
    "myapp/internal/domain"
)

type postgresProductRepo struct {
    db *sql.DB
}

func NewProductRepository(db *sql.DB) domain.ProductRepository {
    return &postgresProductRepo{db: db}
}

func (r *postgresProductRepo) FindByID(ctx context.Context, id uuid.UUID) (*domain.Product, error) {
    var p domain.Product
    err := r.db.QueryRowContext(ctx,
        `SELECT id, name, description, price, stock, created_at, updated_at
         FROM products WHERE id = $1`, id,
    ).Scan(&p.ID, &p.Name, &p.Description, &p.Price, &p.Stock, &p.CreatedAt, &p.UpdatedAt)

    if errors.Is(err, sql.ErrNoRows) {
        return nil, fmt.Errorf("product %s: %w", id, domain.ErrNotFound)
    }
    if err != nil {
        return nil, fmt.Errorf("query product %s: %w", id, err)
    }
    return &p, nil
}

func (r *postgresProductRepo) Create(ctx context.Context, p *domain.Product) error {
    _, err := r.db.ExecContext(ctx,
        `INSERT INTO products (id, name, description, price, stock, created_at, updated_at)
         VALUES ($1, $2, $3, $4, $5, $6, $7)`,
        p.ID, p.Name, p.Description, p.Price, p.Stock, p.CreatedAt, p.UpdatedAt,
    )
    if err != nil {
        return fmt.Errorf("insert product: %w", err)
    }
    return nil
}

Repository wraps sql.ErrNoRowsdomain.ErrNotFound. Database errors never leak to usecase.

For SQLC and GORM implementations, see references/repository-pattern.md.

Layer 4: Delivery (HTTP/Gin Handlers)

Thin handlers: bind → call usecase → respond. No business logic.

// internal/delivery/http/product_handler.go
package http

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

    "github.com/gin-gonic/gin"
    "github.com/google/uuid"
    "myapp/internal/domain"
)

type ProductHandler struct {
    uc     domain.ProductUsecase
    logger *slog.Logger
}

func NewProductHandler(uc domain.ProductUsecase, logger *slog.Logger) *ProductHandler {
    return &ProductHandler{uc: uc, logger: logger}
}

type createProductRequest struct {
    Name        string `json:"name"        binding:"required,min=2,max=200"`
    Description string `json:"description" binding:"max=1000"`
    Price       int64  `json:"price"       binding:"required,gt=0"`
    Stock       int32  `json:"stock"       binding:"min=0"`
}

type productResponse struct {
    ID          string `json:"id"`
    Name        string `json:"name"`
    Description string `json:"description"`
    PriceCents  int64  `json:"price_cents"`
    Stock       int32  `json:"stock"`
    CreatedAt   string `json:"created_at"`
    UpdatedAt   string `json:"updated_at"`
}

func toProductResponse(p *domain.Product) productResponse {
    return productResponse{
        ID: p.ID.String(), Name: p.Name, Description: p.Description,
        PriceCents: p.Price, Stock: p.Stock,
        CreatedAt: p.CreatedAt.Format(time.RFC3339),
        UpdatedAt: p.UpdatedAt.Format(time.RFC3339),
    }
}

func (h *ProductHandler) Create(c *gin.Context) {
    var req createProductRequest
    if err := c.ShouldBindJSON(&req); err != nil {
        c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "validation failed"})
        return
    }

    product, err := h.uc.CreateProduct(c.Request.Context(), domain.CreateProductInput{
        Name:        req.Name,
        Description: req.Description,
        Price:       req.Price,
        Stock:       req.Stock,
    })
    if err != nil {
        handleError(c, err, h.logger)
        return
    }

    c.JSON(http.StatusCreated, toProductResponse(product))
}

func RegisterProductRoutes(rg *gin.RouterGroup, h *ProductHandler) {
    p := rg.Group("/products")
    p.POST("", h.Create)
    p.GET("/:id", h.GetByID)
    p.GET("", h.List)
    p.PUT("/:id", h.Update)
    p.DELETE("/:id", h.Delete)
}

Dependency Injection Bootstrap

Wire everything in main.go — no framework, explicit and debuggable.

// cmd/api/main.go
package main

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

    "github.com/gin-gonic/gin"
    _ "github.com/lib/pq"

    "myapp/config"
    delivery "myapp/internal/delivery/http"
    "myapp/internal/repository"
    "myapp/internal/usecase"
)

func main() {
    logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
    cfg, err := config.Load()
    if err != nil { logger.Error("load config", "error", err); os.Exit(1) }

    db, err := sql.Open("postgres", cfg.DatabaseURL)
    if err != nil { logger.Error("open db", "error", err); os.Exit(1) }
    defer db.Close()
    db.SetMaxOpenConns(25)              // production: use cfg.DBMaxOpenConns
    db.SetMaxIdleConns(10)              // production: use cfg.DBMaxIdleConns
    db.SetConnMaxLifetime(5 * time.Minute) // production: use cfg.DBConnMaxLifetime
    if err := db.PingContext(context.Background()); err != nil {
        logger.Error("ping db", "error", err); os.Exit(1)
    }

    // DI wiring: repo → usecase → handler
    productRepo := repository.NewProductRepository(db)
    productUC := usecase.NewProductUsecase(productRepo)
    productHandler := delivery.NewProductHandler(productUC, logger)

    r := gin.New()
    r.Use(gin.Recovery())
    delivery.RegisterProductRoutes(r.Group("/api/v1"), productHandler)

    srv := &http.Server{Addr: cfg.Addr, Handler: r,
        ReadTimeout: 10 * time.Second, WriteTimeout: 30 * time.Second, IdleTimeout: 60 * time.Second}
    go func() {
        if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
            logger.Error("server error", "error", err); os.Exit(1)
        }
    }()

    quit := make(chan os.Signal, 1)
    signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
    <-quit
    ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
    defer cancel()
    if err := srv.Shutdown(ctx); err != nil { logger.Error("shutdown", "error", err) }
}

DI flow: config → db → repository → usecase → handler → router → server

For DI container pattern and scaling, see references/dependency-injection.md.

Error Flow Across Layers

Repository          Usecase             Delivery
sql.ErrNoRows   →   domain.ErrNotFound  →  404 JSON
unique violation →   domain.ErrConflict  →  409 JSON
business rule    →   domain.ErrValidation → 422 JSON
unknown          →   wrapped error       →  500 JSON (logged)
// internal/domain/errors.go — NO Gin imports here (Rule 1)
package domain

type AppError struct {
    Code    int
    Message string
    Detail  string
}

func (e *AppError) Error() string { return e.Message }

var (
    ErrNotFound   = &AppError{Code: 404, Message: "not found"}
    ErrConflict   = &AppError{Code: 409, Message: "already exists"}
    ErrValidation = &AppError{Code: 422, Message: "validation failed"}
    ErrForbidden  = &AppError{Code: 403, Message: "forbidden"}
)

Pragmatic trade-off: Code aligns with HTTP status codes for simplicity. In non-HTTP contexts (CLI, gRPC), map these codes in the respective delivery layer. For strict domain purity, replace with an ErrorKind enum — see error-handling.md.

Error-to-HTTP mapping lives in the delivery layer (not domain — Gin is a detail):

// internal/delivery/http/errors.go — Gin allowed here (outermost layer)
package http

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

    "github.com/gin-gonic/gin"
    "myapp/internal/domain"
)

func handleError(c *gin.Context, err error, logger *slog.Logger) {
    var appErr *domain.AppError
    if errors.As(err, &appErr) {
        resp := gin.H{"error": appErr.Message}
        if appErr.Code < 500 && appErr.Detail != "" { resp["detail"] = appErr.Detail }
        c.JSON(appErr.Code, resp)
        return
    }
    logger.ErrorContext(c.Request.Context(), "unhandled error", "error", err)
    c.JSON(http.StatusInternalServerError, gin.H{"error": "internal server error"})
}

For detailed error patterns, see references/error-handling.md.

Input Sanitization

Binding tags validate structure but do not sanitize content. Sanitize free-text strings (SanitizeString) at the delivery boundary after ShouldBind* succeeds — before mapping to domain input. SQL parameters ($1, $2...) prevent injection. See references/input-sanitization.md.

Quick Reference

QuestionAnswer
New entity?internal/domain/
Business logic?internal/usecase/
SQL queries?internal/repository/
HTTP handlers?internal/delivery/http/
Interfaces?internal/domain/ (always)
DI wiring?cmd/api/main.go (only dirty place)

Reference Files

Production Checklist

This skill covers architecture and code-level security. For production deployment, combine with golang-gin-best-practices:

  • Rate limiting + CORS middleware → golang-gin-api (references/middleware.md)
  • JWT auth + RBAC → golang-gin-auth (references/jwt-patterns.md)
  • Request ID + timeout middleware → golang-gin-api (references/middleware.md)
  • Health check with DB ping → golang-gin-deploy (references/kubernetes.md)
  • Multi-stage Dockerfile (distroless) → golang-gin-deploy (references/dockerfile.md)
  • OpenTelemetry tracing → golang-gin-deploy

Cross-Skill References (golang-gin-best-practices)

  • golang-gin-api → Delivery: ShouldBind* variants, custom validators, file uploads, CORS/rate-limit, request ID middleware
  • golang-gin-auth → Delivery: JWT middleware, token refresh/blacklist, RBAC with RequireRole
  • golang-gin-database → Repository: GORM associations/hooks/soft-deletes, sqlx, golang-migrate
  • golang-gin-testing → All layers: httptest, table-driven handler tests, testcontainers, e2e with docker-compose
  • golang-gin-deploy → Infra: Dockerfile (distroless), docker-compose + Air, Kubernetes manifests, OpenTelemetry

Source Transparency

This detail page is rendered from real SKILL.md content. Trust labels are metadata-based hints, not a safety guarantee.

Related Skills

Related by shared tags or category signals.

General

golang-gin-api

No summary provided by upstream source.

Repository SourceNeeds Review
General

golang-gin-psql-dba

No summary provided by upstream source.

Repository SourceNeeds Review
General

golang-gin-database

No summary provided by upstream source.

Repository SourceNeeds Review
General

golang-gin-swagger

No summary provided by upstream source.

Repository SourceNeeds Review