Go: struttura standard di un'applicazione

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 Authorization e 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.

Torna su