In questo articolo costruiremo una struttura modulare e manutenibile per una web API in Go con Gin. Divideremo il progetto in blocchi autonomi, ognuno con responsabilità chiare: config, domain, repository, service, http (handlers e middleware) e router, più il bootstrap in cmd/server.
Obiettivi di design
- Separazione dei livelli: ogni layer espone interfacce semplici e non sa come funziona l’implementazione sottostante.
- Testabilità: repository e servizi sono sostituibili tramite interfacce.
- Portabilità: passare dal file store a Postgres non richiede modifiche ai layer alti.
- Configurazione esplicita: la configurazione proviene da variabili d’ambiente con default sicuri.
- HTTP robusto: middleware CORS correttamente configurato per l’header
Authorizatione gestione della preflight.
Albero del progetto
La seguente struttura è una declinazione pratica del layout comunemente usato nella community Go. Le cartelle in internal/ nascondono le API ai consumatori esterni; pkg/ contiene utilità riusabili.
.
├── cmd/
│ └── server/
│ └── main.go
├── internal/
│ ├── config/
│ │ └── config.go
│ ├── domain/
│ │ └── models.go
│ ├── http/
│ │ ├── handlers/
│ │ │ ├── auth.go
│ │ │ └── todos.go
│ │ └── middleware/
│ │ ├── auth.go
│ │ └── cors.go
│ ├── repository/
│ │ ├── repository.go
│ │ └── filestore/
│ │ └── filestore.go
│ ├── router/
│ │ └── router.go
│ └── service/
│ └── service.go
├── pkg/
│ └── hash/
│ └── hash.go
├── .env.example
├── go.mod
├── Makefile
└── README.md
go.mod
Definisce il modulo, la versione del linguaggio e le dipendenze. Mantieni il file essenziale, usa go mod tidy dopo aver incollato il codice per risolvere i vincoli.
module example.com/todoapi
go 1.22
require (
github.com/gin-contrib/cors v1.7.5
github.com/gin-gonic/gin v1.10.0
)
.env.example
Specifica le variabili di configurazione e i loro default. In produzione esponi valori reali tramite secret manager o variabili d’ambiente del processo.
PORT=3000
API_TOKEN=JraolzHAFlL1YBfRysDWWlguXtRALyr429jOkO0w
DATA_DIR=./data
Makefile
Utility per i comandi frequenti: esecuzione, build e allineamento moduli.
.PHONY: run tidy build
run:
go run ./cmd/server
tidy:
go mod tidy
build:
go build -o bin/todoapi ./cmd/server
cmd/server/main.go
Punto di ingresso: carica la configurazione, prepara lo storage, costruisce il router e avvia il server HTTP. Null’altro. Tenere il main snello facilita il riuso e i test degli altri layer.
package main
import (
"log"
"os"
"example.com/todoapi/internal/config"
"example.com/todoapi/internal/repository/filestore"
"example.com/todoapi/internal/router"
)
func main() {
cfg := config.Load()
if err := os.MkdirAll(cfg.DataDir, 0o755); err != nil {
log.Fatalf("create data dir: %v", err)
}
repo, err := filestore.New(cfg)
if err != nil {
log.Fatalf("init filestore: %v", err)
}
r := router.New(cfg, repo)
addr := ":" + cfg.Port
if err := r.Run(addr); err != nil {
log.Fatal(err)
}
}
internal/config/config.go
Layer di configurazione: centralizza la lettura delle variabili d’ambiente e definisce un contratto tipizzato. Questo evita di spargere os.Getenv nel codice applicativo.
package config
import "os"
type Config struct {
Port string
APIToken string
DataDir string
}
func Load() Config {
return Config{
Port: getenv("PORT", "3000"),
APIToken: getenv("API_TOKEN", "JraolzHAFlL1YBfRysDWWlguXtRALyr429jOkO0w"),
DataDir: getenv("DATA_DIR", "./data"),
}
}
func getenv(k, def string) string {
if v := os.Getenv(k); v != "" {
return v
}
return def
}
internal/domain/models.go
Oggetti domain: strutture dati indipendenti dai dettagli infrastrutturali. Qui definisci i contratti che altri layer consumeranno.
package domain
type Todo struct {
ID int `json:"id"`
Title string `json:"title"`
Description string `json:"description"`
Completed bool `json:"completed"`
}
type User struct {
ID int `json:"id"`
Username string `json:"username"`
Password string `json:"password"` // sha256 hex
}
type TodoPatch struct {
Title *string `json:"title"`
Completed *bool `json:"completed"`
Description *string `json:"description"`
}
type LoginBody struct {
Username string `json:"username"`
Password string `json:"password"`
}
type UpdateUserBody struct {
ID int `json:"id"`
Password string `json:"password"`
}
pkg/hash/hash.go
Funzioni riusabili e stabili nel tempo vanno in pkg/. Evita dipendenze circolari e mantieni qui solo utilità generiche.
package hash
import (
"crypto/sha256"
"encoding/hex"
)
func SHA256Hex(s string) string {
sum := sha256.Sum256([]byte(s))
return hex.EncodeToString(sum[:])
}
internal/repository/repository.go
Le interfacce del layer repository definiscono il contratto tra service e persistenza. Questo abilita implementazioni alternative (file, DB, in-memory) senza cambiare il codice di business.
package repository
import "example.com/todoapi/internal/domain"
type Todos interface {
List(page, limit int) (items []domain.Todo, total int, err error)
Search(q string, page, limit int) (items []domain.Todo, total int, err error)
Get(id int) (*domain.Todo, error)
Create(t domain.Todo) (domain.Todo, error)
Patch(id int, p domain.TodoPatch) (*domain.Todo, error)
Delete(id int) (*domain.Todo, error)
}
type Users interface {
Login(username, password string) (*domain.User, error)
UpdatePassword(id int, newPassword string) error
}
internal/repository/filestore/filestore.go
Implementazione file-based del repository. È atomica rispetto al resto del sistema: se in futuro adotti Postgres, sostituisci solo questa cartella. Nota l’uso di sync.Mutex per accedere ai file e la separazione tra helpers di lettura/scrittura e metodi pubblici.
package filestore
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"example.com/todoapi/internal/config"
"example.com/todoapi/internal/domain"
"example.com/todoapi/internal/repository"
"example.com/todoapi/pkg/hash"
)
type store struct {
cfg config.Config
todosPath string
usersPath string
muTodos sync.Mutex
muUsers sync.Mutex
}
func New(cfg config.Config) (struct {
repository.Todos
repository.Users
}, error) {
s := &store{
cfg: cfg,
todosPath: filepath.Join(cfg.DataDir, "todos.json"),
usersPath: filepath.Join(cfg.DataDir, "users.json"),
}
if err := s.ensureFile(s.todosPath); err != nil {
return struct{ repository.Todos; repository.Users }{}, err
}
if err := s.ensureFile(s.usersPath); err != nil {
return struct{ repository.Todos; repository.Users }{}, err
}
return struct {
repository.Todos
repository.Users
}{Todos: s, Users: s}, nil
}
func (s *store) ensureFile(path string) error {
if _, err := os.Stat(path); errors.Is(err, os.ErrNotExist) {
return os.WriteFile(path, []byte("[]"), 0o644)
}
return nil
}
func read[T any](path string, mu *sync.Mutex, out *T) error {
mu.Lock()
defer mu.Unlock()
b, err := os.ReadFile(filepath.Clean(path))
if err != nil {
return err
}
if len(b) == 0 {
return nil
}
return json.Unmarshal(b, out)
}
func write[T any](path string, mu *sync.Mutex, in T) error {
mu.Lock()
defer mu.Unlock()
b, err := json.MarshalIndent(in, "", " ")
if err != nil {
return err
}
return os.WriteFile(filepath.Clean(path), b, 0o644)
}
// Todos
func (s *store) List(page, limit int) ([]domain.Todo, int, error) {
var todos []domain.Todo
if err := read(s.todosPath, &s.muTodos, &todos); err != nil {
return nil, 0, err
}
sort.Slice(todos, func(i, j int) bool { return todos[i].ID > todos[j].ID })
total := len(todos)
start := (page - 1) * limit
if start > total {
start = total
}
end := start + limit
if end > total {
end = total
}
return todos[start:end], total, nil
}
func (s *store) Search(q string, page, limit int) ([]domain.Todo, int, error) {
var todos []domain.Todo
if err := read(s.todosPath, &s.muTodos, &todos); err != nil {
return nil, 0, err
}
sort.Slice(todos, func(i, j int) bool { return todos[i].ID > todos[j].ID })
q = strings.ToLower(q)
var filtered []domain.Todo
for _, t := range todos {
if strings.Contains(strings.ToLower(t.Title), q) ||
strings.Contains(strings.ToLower(t.Description), q) {
filtered = append(filtered, t)
}
}
total := len(filtered)
start := (page - 1) * limit
if start > total {
start = total
}
end := start + limit
if end > total {
end = total
}
return filtered[start:end], total, nil
}
func (s *store) Get(id int) (*domain.Todo, error) {
var todos []domain.Todo
if err := read(s.todosPath, &s.muTodos, &todos); err != nil {
return nil, err
}
for _, t := range todos {
if t.ID == id {
return &t, nil
}
}
return nil, nil
}
func (s *store) nextID(todos []domain.Todo) int {
if len(todos) == 0 {
return 1
}
sort.Slice(todos, func(i, j int) bool { return todos[i].ID > todos[j].ID })
return todos[0].ID + 1
}
func (s *store) Create(t domain.Todo) (domain.Todo, error) {
var todos []domain.Todo
if err := read(s.todosPath, &s.muTodos, &todos); err != nil {
return domain.Todo{}, err
}
t.ID = s.nextID(todos)
todos = append(todos, t)
if err := write(s.todosPath, &s.muTodos, todos); err != nil {
return domain.Todo{}, err
}
return t, nil
}
func (s *store) Patch(id int, p domain.TodoPatch) (*domain.Todo, error) {
var todos []domain.Todo
if err := read(s.todosPath, &s.muTodos, &todos); err != nil {
return nil, err
}
idx := -1
for i := range todos {
if todos[i].ID == id {
idx = i
break
}
}
if idx == -1 {
return nil, nil
}
if p.Title != nil {
todos[idx].Title = *p.Title
}
if p.Description != nil {
todos[idx].Description = *p.Description
}
if p.Completed != nil {
todos[idx].Completed = *p.Completed
}
if err := write(s.todosPath, &s.muTodos, todos); err != nil {
return nil, err
}
return &todos[idx], nil
}
func (s *store) Delete(id int) (*domain.Todo, error) {
var todos []domain.Todo
if err := read(s.todosPath, &s.muTodos, &todos); err != nil {
return nil, err
}
idx := -1
for i := range todos {
if todos[i].ID == id {
idx = i
break
}
}
if idx == -1 {
return nil, nil
}
deleted := todos[idx]
todos = append(todos[:idx], todos[idx+1:]...)
if err := write(s.todosPath, &s.muTodos, todos); err != nil {
return nil, err
}
return &deleted, nil
}
// Users
func (s *store) Login(username, password string) (*domain.User, error) {
var users []domain.User
if err := read(s.usersPath, &s.muUsers, &users); err != nil {
return nil, err
}
enc := hash.SHA256Hex(password)
for _, u := range users {
if u.Username == username && u.Password == enc {
return &u, nil
}
}
return nil, nil
}
func (s *store) UpdatePassword(id int, newPassword string) error {
var users []domain.User
if err := read(s.usersPath, &s.muUsers, &users); err != nil {
return err
}
idx := -1
for i := range users {
if users[i].ID == id {
idx = i
break
}
}
if idx == -1 {
return fmt.Errorf("invalid user")
}
users[idx].Password = hash.SHA256Hex(newPassword)
return write(s.usersPath, &s.muUsers, users)
}
internal/service/service.go
Il servizio incapsula la logica applicativa e orchestra le operazioni sul repository. Qui è il posto giusto per regole di business, validazioni non banali e invarianti di dominio.
package service
import (
"errors"
"example.com/todoapi/internal/domain"
"example.com/todoapi/internal/repository"
)
type Service struct {
Todos repository.Todos
Users repository.Users
}
func (s Service) Login(body domain.LoginBody) (*domain.User, error) {
return s.Users.Login(body.Username, body.Password)
}
func (s Service) UpdatePassword(body domain.UpdateUserBody) error {
if body.ID == 0 || body.Password == "" {
return errors.New("invalid body")
}
return s.Users.UpdatePassword(body.ID, body.Password)
}
func (s Service) ListTodos(page, limit int) ([]domain.Todo, int, error) {
return s.Todos.List(page, limit)
}
func (s Service) SearchTodos(q string, page, limit int) ([]domain.Todo, int, error) {
return s.Todos.Search(q, page, limit)
}
func (s Service) GetTodo(id int) (*domain.Todo, error) { return s.Todos.Get(id) }
func (s Service) CreateTodo(in domain.Todo) (domain.Todo, error) { return s.Todos.Create(in) }
func (s Service) PatchTodo(id int, p domain.TodoPatch) (*domain.Todo, error) {
return s.Todos.Patch(id, p)
}
func (s Service) DeleteTodo(id int) (*domain.Todo, error) { return s.Todos.Delete(id) }
internal/http/middleware/cors.go
Middleware CORS con whitelist di metodi e AllowHeaders che include Authorization. Aggiungiamo anche un semplice short-circuit per le richieste OPTIONS di preflight.
package middleware
import (
"net/http"
"github.com/gin-contrib/cors"
"github.com/gin-gonic/gin"
)
func CORS() gin.HandlerFunc {
cfg := cors.Config{
AllowAllOrigins: true,
AllowMethods: []string{"GET", "POST", "PATCH", "DELETE", "OPTIONS"},
AllowHeaders: []string{"Origin", "Content-Type", "Accept", "Authorization"},
ExposeHeaders: []string{"Content-Length"},
}
return cors.New(cfg)
}
func Preflight() gin.HandlerFunc {
return func(c *gin.Context) {
if c.Request.Method == http.MethodOptions {
c.Status(http.StatusNoContent)
c.Abort()
return
}
c.Next()
}
}
internal/http/middleware/auth.go
Autorizzazione minimale per proteggere le rotte /api/todos* tramite token statico in header Authorization. In un sistema reale sostituiscilo con JWT o sessioni firmate.
package middleware
import (
"net/http"
"example.com/todoapi/internal/config"
"github.com/gin-gonic/gin"
)
func RequireToken(cfg config.Config) gin.HandlerFunc {
return func(c *gin.Context) {
if len(c.FullPath()) > 0 && len(c.FullPath()) >= 9 && c.FullPath()[:9] == "/api/todos" {
token := c.GetHeader("Authorization")
if token == "" || token != cfg.APIToken {
c.AbortWithStatus(http.StatusForbidden)
return
}
}
c.Next()
}
}
internal/http/handlers/auth.go
Handlers per autenticazione e gestione password. Gli handler sono sottili: validano input, delegano ai servizi e modellano la risposta HTTP.
package handlers
import (
"net/http"
"example.com/todoapi/internal/domain"
"example.com/todoapi/internal/service"
"github.com/gin-gonic/gin"
)
type AuthHandler struct{ Svc service.Service }
func (h AuthHandler) Login(c *gin.Context) {
var body domain.LoginBody
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid body."})
return
}
u, err := h.Svc.Login(body)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if u == nil {
c.JSON(http.StatusOK, gin.H{"error": "Invalid login."})
return
}
c.JSON(http.StatusOK, gin.H{"user": u, "token": c.MustGet("api_token").(string)})
}
func (h AuthHandler) UpdatePassword(c *gin.Context) {
var body domain.UpdateUserBody
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid body."})
return
}
if err := h.Svc.UpdatePassword(body); err != nil {
if err.Error() == "invalid body" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid body."})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"success": true})
}
internal/http/handlers/todos.go
CRUD dei Todo. Notare la paginazione costante, la coerenza degli status code e il pattern parse → call service → format.
package handlers
import (
"net/http"
"strconv"
"example.com/todoapi/internal/domain"
"example.com/todoapi/internal/service"
"github.com/gin-gonic/gin"
)
type TodosHandler struct{ Svc service.Service }
const pageLimit = 5
func (h TodosHandler) List(c *gin.Context) {
page := atoiDefault(c.DefaultQuery("page", "1"), 1)
items, total, err := h.Svc.ListTodos(page, pageLimit)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
resp := paginate(items, total, page, pageLimit)
c.JSON(http.StatusOK, resp)
}
func (h TodosHandler) Search(c *gin.Context) {
page := atoiDefault(c.DefaultQuery("page", "1"), 1)
s := c.DefaultQuery("s", "")
items, total, err := h.Svc.SearchTodos(s, page, pageLimit)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
resp := paginate(items, total, page, pageLimit)
c.JSON(http.StatusOK, resp)
}
func (h TodosHandler) Get(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid id."})
return
}
t, err := h.Svc.GetTodo(id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if t == nil {
c.JSON(http.StatusNotFound, nil)
return
}
c.JSON(http.StatusOK, t)
}
func (h TodosHandler) Create(c *gin.Context) {
var in domain.Todo
if err := c.ShouldBindJSON(&in); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid body."})
return
}
created, err := h.Svc.CreateTodo(in)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, created)
}
func (h TodosHandler) Patch(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid id."})
return
}
var p domain.TodoPatch
if err := c.ShouldBindJSON(&p); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid body."})
return
}
updated, err := h.Svc.PatchTodo(id, p)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if updated == nil {
c.JSON(http.StatusNotFound, nil)
return
}
resp := gin.H{"id": id}
if p.Title != nil {
resp["title"] = *p.Title
}
if p.Description != nil {
resp["description"] = *p.Description
}
if p.Completed != nil {
resp["completed"] = *p.Completed
}
c.JSON(http.StatusOK, resp)
}
func (h TodosHandler) Delete(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid id."})
return
}
deleted, err := h.Svc.DeleteTodo(id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if deleted == nil {
c.JSON(http.StatusNotFound, nil)
return
}
c.JSON(http.StatusOK, deleted)
}
// helpers
func atoiDefault(s string, d int) int {
if v, err := strconv.Atoi(s); err == nil && v > 0 {
return v
}
return d
}
func paginate(items []domain.Todo, total, page, limit int) gin.H {
totalPages := (total + limit - 1) / limit
var next, prev int
if page < totalPages {
next = page + 1
}
if page > 1 {
prev = page - 1
}
return gin.H{
"data": items,
"pagination": gin.H{
"totalItems": total,
"totalPages": totalPages,
"currentPage": page,
"next": next,
"previous": prev,
},
}
}
internal/router/router.go
Il router crea l’istanza Gin, applica i middleware cross-cutting, costruisce gli handler e registra le rotte. Il token viene iniettato nel contesto per uso negli handler di login.
package router
import (
"example.com/todoapi/internal/config"
"example.com/todoapi/internal/http/handlers"
"example.com/todoapi/internal/http/middleware"
"example.com/todoapi/internal/repository"
"example.com/todoapi/internal/service"
"github.com/gin-gonic/gin"
)
func New(cfg config.Config, repo struct{ repository.Todos; repository.Users }) *gin.Engine {
gin.SetMode(gin.ReleaseMode)
r := gin.New()
r.Use(gin.Recovery())
r.Use(middleware.CORS())
r.Use(middleware.Preflight())
// espone il token nel contesto (usato nella risposta di /login)
r.Use(func(c *gin.Context) { c.Set("api_token", cfg.APIToken); c.Next() })
// gate di autorizzazione per /api/todos*
r.Use(middleware.RequireToken(cfg))
svc := service.Service{Todos: repo.Todos, Users: repo.Users}
auth := handlers.AuthHandler{Svc: svc}
todos := handlers.TodosHandler{Svc: svc}
api := r.Group("/api")
{
api.POST("/login", auth.Login)
api.POST("/user", auth.UpdatePassword)
api.GET("/todos", todos.List)
api.GET("/todos/search", todos.Search)
api.GET("/todos/:id", todos.Get)
api.POST("/todos", todos.Create)
api.PATCH("/todos/:id", todos.Patch)
api.DELETE("/todos/:id", todos.Delete)
}
return r
}
Vantaggi di questa struttura
- Manutenibilità: i confini chiari tra layer riducono le regressioni quando cambi un componente.
- Estendibilità: puoi sostituire il filestore con un database mantenendo invariati handler e servizi.
- Test: con interfacce nel repository, puoi fare unit test del service e degli handler usando mock.
- Operatività: configurazione centralizzata e server di avvio minimale semplificano il deploy.
Come avviare l’app
Crea i file dati e avvia il server. In ambienti reali useresti un processo di migrazione o seed controllato.
cp .env.example .env
mkdir -p data
echo "[]" > data/todos.json
echo "[]" > data/users.json
go mod tidy
go run ./cmd/server
# oppure
make run
Linee guida finali
- Tenere il dominio pulito: il package domain non deve importare dipendenze infrastrutturali.
- Dipendenze verso il basso: i layer alti dipendono da interfacce, non da implementazioni concrete.
- Configuration-first: nessuna variabile globale non configurabile; tutto passa da config.
- Middleware espliciti: CORS, auth e preflight in un punto chiaro e testabile.
- Responsabilità sottili negli handler: validazione input, chiamata al service, mappatura della risposta.
Questa struttura è una base solida per servizi web in Go di piccole e medie dimensioni; cresce bene quando aggiungi nuove funzionalità, nuova persistenza o protocolli diversi (REST, gRPC) mantenendo i confini tra i layer.