Crear una API REST amb Go des de zero: estructura neta i codi mantenible
Tutorial per construir una API REST en Go amb bona estructura: handlers, serveis, repositoris, errors i configuració.

Tots els tutorials de Go construeixen una API de tasques. Aquest també. Però l’objectiu aquí no és que funcioni el CRUD: és que l’estructura del projecte tingui sentit quan tornis a obrir-lo una setmana després i necessitis afegir un endpoint sense trencar tres fitxers.
He vist massa projectes en Go on tot viu a main.go, els handlers accedeixen directament a la base de dades, i la gestió d’errors és un fmt.Println seguit d’un http.Error amb un missatge genèric. Funciona, és clar. Fins que el projecte creix i cada canvi es converteix en una sessió d’arqueologia.
El que construirem és una API de tasques (TODO) amb separació real entre capes: handlers per a HTTP, serveis per a lògica de negoci, repositoris per a accés a dades, models per al domini, i un sistema d’errors que no et faci endevinar què ha fallat. No és una arquitectura enterprise, és el mínim que necessites perquè el codi sigui mantenible.
Estructura del projecte
Abans d’escriure una sola línia de codi, la decisió més important és com organitzes els fitxers. Go no t’obliga a cap estructura concreta, cosa que és alliberadora i perillosa a parts iguals. Si vols aprofundir en això, tinc un article dedicat a estructura de projecte Go, però per a aquesta API anem amb alguna cosa directa.
todo-api/
├── cmd/
│ └── server/
│ └── main.go
├── internal/
│ ├── config/
│ │ └── config.go
│ ├── handler/
│ │ └── task.go
│ ├── model/
│ │ └── task.go
│ ├── repository/
│ │ └── task.go
│ ├── service/
│ │ └── task.go
│ └── apperror/
│ └── errors.go
├── go.mod
└── go.sumHi ha decisions deliberades aquí:
cmd/server/: El punt d’entrada. Si demà necessites un CLI o un worker, afegeixescmd/cli/icmd/worker/sense tocar res.internal/: Tot el que no hauria de ser importat des de fora del mòdul. Go ho força a nivell de compilador, no és una convenció opcional.- Paquets per responsabilitat, no per feature:
handler,service,repository. Notask/handler.go,task/service.go. En un projecte petit és més clar, i quan creix, el refactor a feature-based és mecànic.
Inicialitzem el mòdul:
go mod init github.com/el-teu-usuari/todo-apiEls models: definir el domini primer
Abans de pensar en HTTP o en bases de dades, necessites saber què és una tasca en el teu domini. No és un JSON, no és una fila de la base de dades. És un tipus de Go amb camps clars.
// internal/model/task.go
package model
import \"time\"
type Task struct {
ID int `json:\"id\"`
Title string `json:\"title\"`
Description string `json:\"description\"`
Done bool `json:\"done\"`
CreatedAt time.Time `json:\"created_at\"`
UpdatedAt time.Time `json:\"updated_at\"`
}
type CreateTaskRequest struct {
Title string `json:\"title\"`
Description string `json:\"description\"`
}
type UpdateTaskRequest struct {
Title *string `json:\"title\"`
Description *string `json:\"description\"`
Done *bool `json:\"done\"`
}Hi ha decisions importants aquí:
Taskés el model de domini. Té tots els camps, inclosos els que genera el sistema (ID,CreatedAt,UpdatedAt).CreateTaskRequestno téIDni timestamps. Aquests camps no els posa l’usuari. Separar el model de creació del model de domini t’evita bugs on algú envia unidal body i espera que es respecti.UpdateTaskRequestusa punters. Un*stringpot sernil, cosa que et permet distingir entre “l’usuari no ha enviat aquest camp” i “l’usuari ha enviat una cadena buida”. Sense punters, no pots fer actualitzacions parcials correctament.
Aquest detall dels punters en l’update és una cosa que molts tutorials ignoren, i quan arribes a producció és d’allò primer que et mossega.
El repositori: accés a dades com a abstracció
El repositori és la capa que sap com emmagatzemar i recuperar tasques. De moment usarem un emmagatzematge en memòria. No perquè sigui útil en producció, sinó perquè desacoblar aquesta capa des del principi significa que canviar a PostgreSQL després és canviar una implementació, no reescriure mig projecte.
// internal/repository/task.go
package repository
import (
\"fmt\"
\"sync\"
\"time\"
\"github.com/el-teu-usuari/todo-api/internal/model\"
)
type TaskRepository interface {
GetAll() ([]model.Task, error)
GetByID(id int) (*model.Task, error)
Create(task *model.Task) error
Update(task *model.Task) error
Delete(id int) error
}
type InMemoryTaskRepository struct {
mu sync.RWMutex
tasks map[int]model.Task
nextID int
}
func NewInMemoryTaskRepository() *InMemoryTaskRepository {
return &InMemoryTaskRepository{
tasks: make(map[int]model.Task),
nextID: 1,
}
}La interfície TaskRepository és el contracte. Qualsevol implementació que compleixi aquests cinc mètodes serveix. Això no és sobreenginyeria: és el que et permet escriure tests sense base de dades i canviar d’emmagatzematge sense tocar la lògica de negoci.
Ara les implementacions concretes:
func (r *InMemoryTaskRepository) GetAll() ([]model.Task, error) {
r.mu.RLock()
defer r.mu.RUnlock()
tasks := make([]model.Task, 0, len(r.tasks))
for _, t := range r.tasks {
tasks = append(tasks, t)
}
return tasks, nil
}
func (r *InMemoryTaskRepository) GetByID(id int) (*model.Task, error) {
r.mu.RLock()
defer r.mu.RUnlock()
task, exists := r.tasks[id]
if !exists {
return nil, fmt.Errorf(\"task with id %d not found\", id)
}
return &task, nil
}
func (r *InMemoryTaskRepository) Create(task *model.Task) error {
r.mu.Lock()
defer r.mu.Unlock()
task.ID = r.nextID
task.CreatedAt = time.Now()
task.UpdatedAt = time.Now()
r.tasks[task.ID] = *task
r.nextID++
return nil
}
func (r *InMemoryTaskRepository) Update(task *model.Task) error {
r.mu.Lock()
defer r.mu.Unlock()
if _, exists := r.tasks[task.ID]; !exists {
return fmt.Errorf(\"task with id %d not found\", task.ID)
}
task.UpdatedAt = time.Now()
r.tasks[task.ID] = *task
return nil
}
func (r *InMemoryTaskRepository) Delete(id int) error {
r.mu.Lock()
defer r.mu.Unlock()
if _, exists := r.tasks[id]; !exists {
return fmt.Errorf(\"task with id %d not found\", id)
}
delete(r.tasks, id)
return nil
}El sync.RWMutex és necessari perquè un servidor HTTP gestiona peticions concurrents. Sense ell, dues peticions simultànies escrivint al mapa causen un data race que Go detecta en runtime amb un panic. Això és una cosa que no veus en desenvolupament amb un sol client, però que explota en producció.
RLock per a lectures (permet múltiples lectors concurrents) i Lock per a escriptures (accés exclusiu). És la diferència entre un servidor que escala i un que serialitza-ho tot.
Gestió d’errors: no deixis que HTTP decideixi la teva lògica
Aquest és el punt on la majoria de tutorials fallen. Retornen http.StatusInternalServerError per a tot, o pitjor, deixen que el handler decideixi quin codi HTTP correspon a cada error del repositori. Això acobla les capes i t’obliga a importar net/http en llocs on no hauria d’estar.
La solució és definir els teus propis tipus d’error en el domini:
// internal/apperror/errors.go
package apperror
import \"fmt\"
type ErrorType int
const (
NotFound ErrorType = iota
Validation
Conflict
Internal
)
type AppError struct {
Type ErrorType
Message string
Err error
}
func (e *AppError) Error() string {
if e.Err != nil {
return fmt.Sprintf(\"%s: %v\", e.Message, e.Err)
}
return e.Message
}
func (e *AppError) Unwrap() error {
return e.Err
}
func NewNotFound(msg string) *AppError {
return &AppError{Type: NotFound, Message: msg}
}
func NewValidation(msg string) *AppError {
return &AppError{Type: Validation, Message: msg}
}
func NewInternal(msg string, err error) *AppError {
return &AppError{Type: Internal, Message: msg, Err: err}
}Ara el servei pot retornar apperror.NewNotFound(\"task not found\") sense saber res d’HTTP, i el handler pot mapejar NotFound a 404, Validation a 400, etc. Cada capa parla el seu idioma.
Si vols aprofundir en patrons d’errors a Go, et recomano dedicar-hi temps perquè és una de les parts del llenguatge que més afecta la qualitat del codi a llarg termini.
El servei: lògica de negoci separada d’HTTP
El servei és on viu la lògica que no és ni HTTP ni accés a dades. Validacions de negoci, transformacions, regles que apliquen independentment de si la petició ve d’una API REST, un CLI o un test.
// internal/service/task.go
package service
import (
\"errors\"
\"strings\"
\"github.com/el-teu-usuari/todo-api/internal/apperror\"
\"github.com/el-teu-usuari/todo-api/internal/model\"
\"github.com/el-teu-usuari/todo-api/internal/repository\"
)
type TaskService struct {
repo repository.TaskRepository
}
func NewTaskService(repo repository.TaskRepository) *TaskService {
return &TaskService{repo: repo}
}
func (s *TaskService) GetAll() ([]model.Task, error) {
return s.repo.GetAll()
}
func (s *TaskService) GetByID(id int) (*model.Task, error) {
task, err := s.repo.GetByID(id)
if err != nil {
return nil, apperror.NewNotFound(\"task not found\")
}
return task, nil
}
func (s *TaskService) Create(req model.CreateTaskRequest) (*model.Task, error) {
if strings.TrimSpace(req.Title) == \"\" {
return nil, apperror.NewValidation(\"title is required\")
}
task := &model.Task{
Title: strings.TrimSpace(req.Title),
Description: strings.TrimSpace(req.Description),
Done: false,
}
if err := s.repo.Create(task); err != nil {
return nil, apperror.NewInternal(\"failed to create task\", err)
}
return task, nil
}
func (s *TaskService) Update(id int, req model.UpdateTaskRequest) (*model.Task, error) {
task, err := s.repo.GetByID(id)
if err != nil {
return nil, apperror.NewNotFound(\"task not found\")
}
if req.Title != nil {
title := strings.TrimSpace(*req.Title)
if title == \"\" {
return nil, apperror.NewValidation(\"title cannot be empty\")
}
task.Title = title
}
if req.Description != nil {
task.Description = strings.TrimSpace(*req.Description)
}
if req.Done != nil {
task.Done = *req.Done
}
if err := s.repo.Update(task); err != nil {
return nil, apperror.NewInternal(\"failed to update task\", err)
}
return task, nil
}
func (s *TaskService) Delete(id int) error {
_, err := s.repo.GetByID(id)
if err != nil {
return apperror.NewNotFound(\"task not found\")
}
if err := s.repo.Delete(id); err != nil {
return apperror.NewInternal(\"failed to delete task\", err)
}
return nil
}Observa diverses coses:
- El servei rep la interfície
TaskRepository, no la implementació concreta. Això significa que als tests pots passar un mock sense tocar res. - Les validacions estan al servei, no al handler. El handler només parseja el JSON i crida el servei. Si demà afegeixes un CLI que crea tasques, les validacions segueixen aplicant.
- L’
Updateusa els punters delUpdateTaskRequest. Només modifica els camps que l’usuari ha enviat. Un PATCH real, no un PUT disfressat. - Els errors del repositori es transformen en
AppError. El handler mai veu unfmt.Errorfdel repo directament.
Fixa’t també que GetAll no transforma l’error. Si el repositori falla, l’error puja tal qual. No sempre necessites embolcallar cada error: de vegades la transparència és millor que la cerimònia.
Els handlers: la capa HTTP
El handler és la frontera entre HTTP i el teu domini. La seva feina és simple: llegir la petició, cridar el servei, escriure la resposta. Res de lògica de negoci aquí.
// internal/handler/task.go
package handler
import (
\"encoding/json\"
\"errors\"
\"net/http\"
\"strconv\"
\"github.com/el-teu-usuari/todo-api/internal/apperror\"
\"github.com/el-teu-usuari/todo-api/internal/model\"
\"github.com/el-teu-usuari/todo-api/internal/service\"
)
type TaskHandler struct {
service *service.TaskService
}
func NewTaskHandler(service *service.TaskService) *TaskHandler {
return &TaskHandler{service: service}
}
type ErrorResponse struct {
Error string `json:\"error\"`
}
func (h *TaskHandler) writeJSON(w http.ResponseWriter, status int, data any) {
w.Header().Set(\"Content-Type\", \"application/json\")
w.WriteHeader(status)
json.NewEncoder(w).Encode(data)
}
func (h *TaskHandler) writeError(w http.ResponseWriter, err error) {
var appErr *apperror.AppError
if errors.As(err, &appErr) {
status := http.StatusInternalServerError
switch appErr.Type {
case apperror.NotFound:
status = http.StatusNotFound
case apperror.Validation:
status = http.StatusBadRequest
case apperror.Conflict:
status = http.StatusConflict
}
h.writeJSON(w, status, ErrorResponse{Error: appErr.Message})
return
}
h.writeJSON(w, http.StatusInternalServerError, ErrorResponse{Error: \"internal server error\"})
}El mètode writeError és el que fa la traducció entre els errors del domini i els codis HTTP. Un switch net, sense ifs niuats, fàcil d’estendre. Quan afegeixis un nou tipus d’error (per exemple, Unauthorized), només afegeixes un case aquí.
Ara els handlers concrets:
func (h *TaskHandler) GetAll(w http.ResponseWriter, r *http.Request) {
tasks, err := h.service.GetAll()
if err != nil {
h.writeError(w, err)
return
}
h.writeJSON(w, http.StatusOK, tasks)
}
func (h *TaskHandler) GetByID(w http.ResponseWriter, r *http.Request) {
id, err := strconv.Atoi(r.PathValue(\"id\"))
if err != nil {
h.writeJSON(w, http.StatusBadRequest, ErrorResponse{Error: \"invalid id\"})
return
}
task, err := h.service.GetByID(id)
if err != nil {
h.writeError(w, err)
return
}
h.writeJSON(w, http.StatusOK, task)
}
func (h *TaskHandler) Create(w http.ResponseWriter, r *http.Request) {
var req model.CreateTaskRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
h.writeJSON(w, http.StatusBadRequest, ErrorResponse{Error: \"invalid request body\"})
return
}
task, err := h.service.Create(req)
if err != nil {
h.writeError(w, err)
return
}
h.writeJSON(w, http.StatusCreated, task)
}
func (h *TaskHandler) Update(w http.ResponseWriter, r *http.Request) {
id, err := strconv.Atoi(r.PathValue(\"id\"))
if err != nil {
h.writeJSON(w, http.StatusBadRequest, ErrorResponse{Error: \"invalid id\"})
return
}
var req model.UpdateTaskRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
h.writeJSON(w, http.StatusBadRequest, ErrorResponse{Error: \"invalid request body\"})
return
}
task, err := h.service.Update(id, req)
if err != nil {
h.writeError(w, err)
return
}
h.writeJSON(w, http.StatusOK, task)
}
func (h *TaskHandler) Delete(w http.ResponseWriter, r *http.Request) {
id, err := strconv.Atoi(r.PathValue(\"id\"))
if err != nil {
h.writeJSON(w, http.StatusBadRequest, ErrorResponse{Error: \"invalid id\"})
return
}
if err := h.service.Delete(id); err != nil {
h.writeError(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}Cada handler segueix el mateix patró: parsejar l’input, cridar el servei, retornar resultat o error. Res de lògica de negoci. Res d’accés a dades. Si llegeixes un handler i necessites més de 10 segons per entendre què fa, alguna cosa no funciona.
Un detall important: r.PathValue(\"id\") és de Go 1.22+, que va afegir suport natiu per a paràmetres en rutes. Abans necessitaves un router extern com Gorilla Mux o Chi per a quelcom tan bàsic. Si estàs en una versió anterior, hauràs d’usar un router de tercers o parsejar la URL a mà.
Routing: connectant rutes i handlers
Amb Go 1.22, la biblioteca estàndard net/http suporta mètodes HTTP i paràmetres de ruta. Això significa que per a una API com la nostra, no necessites Gin, Chi ni cap framework extern.
func setupRoutes(taskHandler *handler.TaskHandler) *http.ServeMux {
mux := http.NewServeMux()
mux.HandleFunc(\"GET /api/tasks\", taskHandler.GetAll)
mux.HandleFunc(\"GET /api/tasks/{id}\", taskHandler.GetByID)
mux.HandleFunc(\"POST /api/tasks\", taskHandler.Create)
mux.HandleFunc(\"PUT /api/tasks/{id}\", taskHandler.Update)
mux.HandleFunc(\"DELETE /api/tasks/{id}\", taskHandler.Delete)
return mux
}Net, sense màgia, sense dependències. El patró \"GET /api/tasks/{id}\" li diu al mux que només faci match amb peticions GET que tinguin aquell path, i que extregui {id} com a paràmetre accessible amb r.PathValue(\"id\").
I si prefereixes Gin?
Si la teva API creixerà i necessites middleware més sofisticat, validació de bindings, o simplement prefereixes l’ergonomia de Gin, l’adaptació és directa:
func setupGinRoutes(taskHandler *handler.TaskHandler) *gin.Engine {
r := gin.Default()
api := r.Group(\"/api\")
{
api.GET(\"/tasks\", wrapHandler(taskHandler.GetAll))
api.GET(\"/tasks/:id\", wrapHandler(taskHandler.GetByID))
api.POST(\"/tasks\", wrapHandler(taskHandler.Create))
api.PUT(\"/tasks/:id\", wrapHandler(taskHandler.Update))
api.DELETE(\"/tasks/:id\", wrapHandler(taskHandler.Delete))
}
return r
}
func wrapHandler(h http.HandlerFunc) gin.HandlerFunc {
return func(c *gin.Context) {
h(c.Writer, c.Request)
}
}Però la meva recomanació per a una API nova en Go 1.22+: comença amb la biblioteca estàndard. Afegeixes dependències quan les necessites, no abans. Go té aquesta virtut: la stdlib és suficient per al 80% dels casos.
Configuració: variables d’entorn
La configuració hardcoded és l’origen de la meitat dels bugs de desplegament. Port del servidor, URLs de base de dades, timeouts: tot això ha de venir de l’entorn.
// internal/config/config.go
package config
import (
\"fmt\"
\"os\"
\"strconv\"
\"time\"
)
type Config struct {
Port int
ReadTimeout time.Duration
WriteTimeout time.Duration
ShutdownTimeout time.Duration
}
func Load() (*Config, error) {
port, err := getEnvInt(\"PORT\", 8080)
if err != nil {
return nil, fmt.Errorf(\"invalid PORT: %w\", err)
}
readTimeout, err := getEnvDuration(\"READ_TIMEOUT\", 5*time.Second)
if err != nil {
return nil, fmt.Errorf(\"invalid READ_TIMEOUT: %w\", err)
}
writeTimeout, err := getEnvDuration(\"WRITE_TIMEOUT\", 10*time.Second)
if err != nil {
return nil, fmt.Errorf(\"invalid WRITE_TIMEOUT: %w\", err)
}
shutdownTimeout, err := getEnvDuration(\"SHUTDOWN_TIMEOUT\", 15*time.Second)
if err != nil {
return nil, fmt.Errorf(\"invalid SHUTDOWN_TIMEOUT: %w\", err)
}
return &Config{
Port: port,
ReadTimeout: readTimeout,
WriteTimeout: writeTimeout,
ShutdownTimeout: shutdownTimeout,
}, nil
}
func getEnvInt(key string, defaultVal int) (int, error) {
val, exists := os.LookupEnv(key)
if !exists {
return defaultVal, nil
}
return strconv.Atoi(val)
}
func getEnvDuration(key string, defaultVal time.Duration) (time.Duration, error) {
val, exists := os.LookupEnv(key)
if !exists {
return defaultVal, nil
}
return time.ParseDuration(val)
}Cada valor té un default raonable, i si algú passa un valor invàlid, el servidor falla en arrencar amb un missatge clar en lloc de comportar-se de forma impredictible en runtime.
No faig servir biblioteques com Viper ni godotenv aquí. Per a una API amb quatre variables, os.LookupEnv i funcions helper són més que suficients. Quan tinguis 20 variables i necessitis validació complexa, aleshores avalua si una biblioteca aporta alguna cosa.
El main.go: connectant-ho tot
El main.go és l’únic lloc on totes les capes es coneixen entre si. Aquí és on fas el wiring manual de dependències. No hi ha injecció de dependències automàtica com a Spring, i això és un avantatge: pots llegir el main i saber exactament com es munta el servidor.
// cmd/server/main.go
package main
import (
\"context\"
\"fmt\"
\"log\"
\"net/http\"
\"os\"
\"os/signal\"
\"syscall\"
\"github.com/el-teu-usuari/todo-api/internal/config\"
\"github.com/el-teu-usuari/todo-api/internal/handler\"
\"github.com/el-teu-usuari/todo-api/internal/repository\"
\"github.com/el-teu-usuari/todo-api/internal/service\"
)
func main() {
cfg, err := config.Load()
if err != nil {
log.Fatalf(\"failed to load config: %v\", err)
}
// Wiring de dependències
taskRepo := repository.NewInMemoryTaskRepository()
taskService := service.NewTaskService(taskRepo)
taskHandler := handler.NewTaskHandler(taskService)
// Rutes
mux := http.NewServeMux()
mux.HandleFunc(\"GET /api/tasks\", taskHandler.GetAll)
mux.HandleFunc(\"GET /api/tasks/{id}\", taskHandler.GetByID)
mux.HandleFunc(\"POST /api/tasks\", taskHandler.Create)
mux.HandleFunc(\"PUT /api/tasks/{id}\", taskHandler.Update)
mux.HandleFunc(\"DELETE /api/tasks/{id}\", taskHandler.Delete)
// Servidor amb timeouts
server := &http.Server{
Addr: fmt.Sprintf(\":%d\", cfg.Port),
Handler: mux,
ReadTimeout: cfg.ReadTimeout,
WriteTimeout: cfg.WriteTimeout,
}
// Graceful shutdown
go func() {
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
<-sigChan
log.Println(\"shutting down server...\")
ctx, cancel := context.WithTimeout(context.Background(), cfg.ShutdownTimeout)
defer cancel()
if err := server.Shutdown(ctx); err != nil {
log.Printf(\"server shutdown error: %v\", err)
}
}()
log.Printf(\"server starting on port %d\", cfg.Port)
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf(\"server error: %v\", err)
}
log.Println(\"server stopped\")
}Tres coses que molts tutorials ometen i que aquí estan des del principi:
Timeouts al servidor. Un http.Server sense ReadTimeout ni WriteTimeout accepta connexions que poden quedar-se obertes indefinidament. En producció, això és un vector d’atac trivial (slowloris) i un leak de recursos.
Graceful shutdown. Quan el procés rep un SIGINT o SIGTERM (que és el que envia Kubernetes, Docker, o un Ctrl+C), no talla les connexions obertes de cop. Li dona un temps al servidor per acabar les peticions en curs. Sense això, els teus usuaris veuen errors 502 cada vegada que desplegues.
Wiring explícit. Repo -> Service -> Handler. Pots llegir les tres línies i entendre la cadena de dependències completa. No hi ha contenidors d’IoC, no hi ha reflexió, no hi ha autoconfiguració que endevini què injectar. En Go, això és una feature.
Provant l’API
Arranquem el servidor:
go run ./cmd/server/I provem amb curl:
# Crear una tasca
curl -s -X POST http://localhost:8080/api/tasks \
-H \"Content-Type: application/json\" \
-d '{\"title\": \"Aprendre Go\", \"description\": \"Construir una API REST\"}' | jq
# Resposta:
# {
# \"id\": 1,
# \"title\": \"Aprendre Go\",
# \"description\": \"Construir una API REST\",
# \"done\": false,
# \"created_at\": \"2026-07-02T10:30:00Z\",
# \"updated_at\": \"2026-07-02T10:30:00Z\"
# }
# Llistar totes les tasques
curl -s http://localhost:8080/api/tasks | jq
# Obtenir una tasca per ID
curl -s http://localhost:8080/api/tasks/1 | jq
# Actualitzar parcialment (només marcar com a completada)
curl -s -X PUT http://localhost:8080/api/tasks/1 \
-H \"Content-Type: application/json\" \
-d '{\"done\": true}' | jq
# Eliminar una tasca
curl -s -X DELETE http://localhost:8080/api/tasks/1 -w \"\n%{http_code}\n\"
# 204
# Intentar obtenir una tasca que no existeix
curl -s http://localhost:8080/api/tasks/999 | jq
# {
# \"error\": \"task not found\"
# }L’API retorna JSON consistent tant en èxit com en error. No hi ha HTMLs d’error per defecte, no hi ha missatges de Go crus. Cada resposta té un format predible que un frontend o un client pot parsejar sense condicions especials.
El que no hi és i per què
Aquest article cobreix l’estructura, no totes les features de producció. Hi ha coses que deliberadament no hi són però que necessites abans de desplegar:
Middleware de logging. Cada petició hauria de registrar mètode, path, codi d’estat i durada. Amb net/http pots escriure un middleware que embolcalli el handler en 15 línies.
Base de dades real. El repositori en memòria és per demostrar el patró. Per a producció necessites PostgreSQL o una altra base de dades darrere de la mateixa interfície TaskRepository.
Validació més robusta. El servei valida que el títol no estigui buit, però en un projecte real necessites validació de longituds, formats, i probablement una biblioteca com go-playground/validator.
Tests. Gràcies a les interfícies, testejar cada capa és directe: mock del repositori per testejar el servei, mock del servei per testejar el handler. Mereix un article propi sobre testing a Go.
Docker. Per desplegar això necessites un Dockerfile multi-stage que compili el binari i l’executi en una imatge mínima. Ho cobrixo a dockeritzar una API Go.
Autenticació i autorització. Middleware de JWT, API keys, o el que necessiti el teu cas. No hi és perquè és ortogonal a l’estructura, però és imprescindible abans d’exposar l’API.
Visió de conjunt
Fem zoom out. El flux complet d’una petició HTTP a través d’aquesta arquitectura és:
Petició HTTP
│
▼
Handler → Parseja request, crida el servei, escriu response
│
▼
Service → Valida, aplica lògica de negoci, crida el repositori
│
▼
Repository → Llegeix/escriu dades (memòria, PostgreSQL, el que sigui)
│
▼
Model → Tipus de domini que travessen totes les capes
│
▼
AppError → Errors tipats que el handler tradueix a HTTPCada capa té una única responsabilitat. Cada capa es comunica amb la següent a través d’interfícies o tipus compartits. Cap capa sap com funcionen les altres per dins.
Això no és arquitectura hexagonal ni clean architecture amb ports i adaptadors i 47 interfícies. És separació de responsabilitats bàsica amb les eines que Go et dóna. I per al 90% de les APIs que construiràs, és suficient.
La clau no és l’estructura en si, és la disciplina de mantenir-la. Quan tinguis pressa i vulguis posar una query SQL dins d’un handler “només aquesta vegada”, recorda que aquesta excepció es converteix en la norma en dos sprints.
Què ve després
Des d’aquesta base, els passos naturals següents són:
- Afegir persistència real amb PostgreSQL: implementar
TaskRepositoryambdatabase/sqlosqlx. - Escriure tests per a cada capa aprofitant les interfícies que ja tens. Ho detallo a testing a Go.
- Containeritzar l’aplicació amb un Dockerfile multi-stage per tenir un binari de 15MB en una imatge scratch. Veure dockeritzar API Go.
- Afegir middleware de logging, recovery i CORS.
- Documentar l’API amb OpenAPI/Swagger si l’han de consumir altres equips.
L’estructura que hem muntat suporta tots aquests canvis sense reescriure el que ja funciona. Aquell era l’objectiu des del principi: no fer el TODO més bonic del món, sinó fer-ne un que puguis seguir mantenint.


