Crear una API de tasques amb Go, PostgreSQL i Docker
Projecte complet: API REST en Go amb PostgreSQL, Docker, migracions, tests i estructura neta. De zero a desplegament.

Una altra API de tasques. Ho sé. Però la majoria de tutorials s’aturen en un CRUD contra una base de dades hardcodejada, sense migracions, sense tests d’integració, sense Docker, i amb tota la lògica ficada a main.go. Quan acabes tens alguna cosa que funciona però que no t’ensenya res sobre com es construeix programari de veritat.
Aquest projecte és diferent. Construirem el mateix (una API de tasques) però prenent les decisions que prendries en un projecte real: estructura per capes, migracions amb golang-migrate, repositori contra PostgreSQL, tests unitaris amb mocks, tests d’integració amb testcontainers, configuració per variables d’entorn, i Docker perquè qualsevol pugui aixecar el projecte amb una sola comanda.
El CRUD és el vehicle. Les decisions d’arquitectura, testing i desplegament són la destinació.
Si vens de l’article de API REST amb Go on fèiem servir emmagatzematge en memòria, aquesta és l’evolució natural: connectar amb una base de dades real i preparar el projecte per a producció.
Què construirem
Una API REST amb aquests endpoints:
| Mètode | Ruta | Descripció |
|---|---|---|
GET | /api/tasks | Llistar totes les tasques |
GET | /api/tasks/{id} | Obtenir una tasca per ID |
POST | /api/tasks | Crear una nova tasca |
PUT | /api/tasks/{id} | Actualitzar una tasca |
DELETE | /api/tasks/{id} | Eliminar una tasca |
I aquestes peces:
- Go + Gin com a framework HTTP
- PostgreSQL com a base de dades
- golang-migrate per a migracions d’esquema
- pgx com a driver de PostgreSQL (no el database/sql genèric)
- testcontainers-go per a tests d’integració reals
- Docker + docker-compose per aixecar tot l’entorn
No farem servir ORMs. Si vols entendre com funciona la interacció amb la base de dades, has d’escriure SQL. Un ORM t’amaga exactament les parts que necessites dominar.
Estructura del projecte
task-api/
├── cmd/
│ └── server/
│ └── main.go
├── internal/
│ ├── config/
│ │ └── config.go
│ ├── handler/
│ │ └── task.go
│ ├── model/
│ │ └── task.go
│ ├── repository/
│ │ ├── task.go
│ │ └── task_test.go
│ ├── service/
│ │ ├── task.go
│ │ └── task_test.go
│ └── apperror/
│ └── errors.go
├── migrations/
│ ├── 000001_create_tasks_table.up.sql
│ └── 000001_create_tasks_table.down.sql
├── docker-compose.yml
├── Dockerfile
├── .env.example
├── go.mod
└── go.sumSi has llegit l’article sobre estructura de projecte, això et resultarà familiar. cmd/ per a punts d’entrada, internal/ per a codi privat del mòdul, paquets per responsabilitat. La novetat aquí és migrations/ per als fitxers SQL de golang-migrate.
Inicialitzem:
go mod init github.com/el-teu-usuari/task-apiDependències que necessitarem:
go get github.com/gin-gonic/gin
go get github.com/jackc/pgx/v5
go get github.com/jackc/pgx/v5/pgxpool
go get -tags 'postgres' github.com/golang-migrate/migrate/v4
go get github.com/testcontainers/testcontainers-go
go get github.com/testcontainers/testcontainers-go/modules/postgresBase de dades: esquema i migracions amb golang-migrate
Abans d’escriure Go, definim la base de dades. L’esquema és senzill però té el que importa: tipus correctes, timestamps automàtics, i un índex que té sentit.
La migració
-- migrations/000001_create_tasks_table.up.sql
CREATE TABLE IF NOT EXISTS tasks (
id SERIAL PRIMARY KEY,
title VARCHAR(255) NOT NULL,
description TEXT NOT NULL DEFAULT "',
done BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_tasks_done ON tasks (done);-- migrations/000001_create_tasks_table.down.sql
DROP TABLE IF EXISTS tasks;Decisions que importen:
SERIALen lloc deBIGSERIAL: Per a una taula de tasques, unint4que suporta fins a 2 mil milions de files és més que suficient. No facis servirBIGSERIALper defecte; gasta el doble d’espai en índexs sense necessitat.TIMESTAMPTZen lloc deTIMESTAMP: Sempre amb zona horària. Sense ella, cada aplicació interpreta les dates com li sembla. En producció això causa bugs subtils i dolorosos.DEFAULT ''en description: Evita haver de gestionarNULLen strings. Un string buit és més fàcil de gestionar en Go que un*stringnullable.- Índex en
done: Si el cas d’ús principal és “dona’m les tasques pendents”, aquest índex accelera la consulta més comuna. No és que sigui obligatori per a un projecte petit, però és el tipus de decisió que hauries de prendre des del principi.
Aplicar migracions des de Go
Podries executar les migracions des de la línia de comandes amb el CLI de golang-migrate, però integrar-les en l’arrencada de l’aplicació té avantatges: el servidor no arrenca si la base de dades no està al dia, i no depens que algú recordi executar una comanda abans del desplegament.
// internal/config/migrate.go
package config
import (
"fmt"
"github.com/golang-migrate/migrate/v4"
_ "github.com/golang-migrate/migrate/v4/database/postgres"
_ "github.com/golang-migrate/migrate/v4/source/file"
)
func RunMigrations(databaseURL string, migrationsPath string) error {
m, err := migrate.New(
fmt.Sprintf("file://%s", migrationsPath),
databaseURL,
)
if err != nil {
return fmt.Errorf("failed to create migrate instance: %w", err)
}
defer m.Close()
if err := m.Up(); err != nil && err != migrate.ErrNoChange {
return fmt.Errorf("failed to run migrations: %w", err)
}
return nil
}El migrate.ErrNoChange no és un error real: significa que les migracions ja estan aplicades. Si el tractes com a error, el servidor falla a cada reinici després del primer.
Si vols aprofundir en la connexió i el maneig de PostgreSQL amb Go, tinc un article dedicat on cobrim pgx en detall.
Models i capa de repositori
El model de domini
// 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" binding:"required"`
Description string `json:"description"`
}
type UpdateTaskRequest struct {
Title *string `json:"title"`
Description *string `json:"description"`
Done *bool `json:"done"`
}Els punters a UpdateTaskRequest són clau. Sense ells no pots distingir entre “l’usuari no ha enviat aquest camp” i “l’usuari ha enviat un valor buit”. És la diferència entre un PATCH real i un PUT disfressat. Si véns de llenguatges amb Optional o null, els punters són l’equivalent en Go.
El tag binding:"required" és de Gin. Valida que el camp existeixi al JSON abans que arribi al servei.
La interfície del repositori
// internal/repository/task.go
package repository
import (
"context"
"github.com/el-teu-usuari/task-api/internal/model"
)
type TaskRepository interface {
GetAll(ctx context.Context) ([]model.Task, error)
GetByID(ctx context.Context, id int) (*model.Task, error)
Create(ctx context.Context, task *model.Task) error
Update(ctx context.Context, task *model.Task) error
Delete(ctx context.Context, id int) error
}Diferència crítica respecte a l’article de API REST amb Go on fèiem servir un repositori en memòria: aquí tots els mètodes reben context.Context. Quan parles amb una base de dades, necessites context per a timeouts i cancel·lacions. Si una petició HTTP es cancel·la, el context es propaga fins a la query i PostgreSQL deixa de treballar. Sense context, la query continua executant-se tot i que ningú esperi el resultat.
La implementació amb PostgreSQL
// internal/repository/postgres_task.go
package repository
import (
"context"
"fmt"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/el-teu-usuari/task-api/internal/model"
)
type PostgresTaskRepository struct {
pool *pgxpool.Pool
}
func NewPostgresTaskRepository(pool *pgxpool.Pool) *PostgresTaskRepository {
return &PostgresTaskRepository{pool: pool}
}
func (r *PostgresTaskRepository) GetAll(ctx context.Context) ([]model.Task, error) {
rows, err := r.pool.Query(ctx,
"SELECT id, title, description, done, created_at, updated_at FROM tasks ORDER BY created_at DESC",
)
if err != nil {
return nil, fmt.Errorf("query tasks: %w", err)
}
defer rows.Close()
var tasks []model.Task
for rows.Next() {
var t model.Task
if err := rows.Scan(&t.ID, &t.Title, &t.Description, &t.Done, &t.CreatedAt, &t.UpdatedAt); err != nil {
return nil, fmt.Errorf("scan task: %w", err)
}
tasks = append(tasks, t)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate tasks: %w", err)
}
return tasks, nil
}
func (r *PostgresTaskRepository) GetByID(ctx context.Context, id int) (*model.Task, error) {
var t model.Task
err := r.pool.QueryRow(ctx,
"SELECT id, title, description, done, created_at, updated_at FROM tasks WHERE id = $1",
id,
).Scan(&t.ID, &t.Title, &t.Description, &t.Done, &t.CreatedAt, &t.UpdatedAt)
if err != nil {
if err == pgx.ErrNoRows {
return nil, fmt.Errorf("task with id %d not found", id)
}
return nil, fmt.Errorf("query task: %w", err)
}
return &t, nil
}
func (r *PostgresTaskRepository) Create(ctx context.Context, task *model.Task) error {
err := r.pool.QueryRow(ctx,
`INSERT INTO tasks (title, description, done, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5)
RETURNING id, created_at, updated_at`,
task.Title, task.Description, task.Done, time.Now(), time.Now(),
).Scan(&task.ID, &task.CreatedAt, &task.UpdatedAt)
if err != nil {
return fmt.Errorf("insert task: %w", err)
}
return nil
}
func (r *PostgresTaskRepository) Update(ctx context.Context, task *model.Task) error {
task.UpdatedAt = time.Now()
result, err := r.pool.Exec(ctx,
`UPDATE tasks
SET title = $1, description = $2, done = $3, updated_at = $4
WHERE id = $5`,
task.Title, task.Description, task.Done, task.UpdatedAt, task.ID,
)
if err != nil {
return fmt.Errorf("update task: %w", err)
}
if result.RowsAffected() == 0 {
return fmt.Errorf("task with id %d not found", task.ID)
}
return nil
}
func (r *PostgresTaskRepository) Delete(ctx context.Context, id int) error {
result, err := r.pool.Exec(ctx,
"DELETE FROM tasks WHERE id = $1",
id,
)
if err != nil {
return fmt.Errorf("delete task: %w", err)
}
if result.RowsAffected() == 0 {
return fmt.Errorf("task with id %d not found", id)
}
return nil
}Coses a notar:
pgxpool.Pool, no una connexió simple. El pool gestiona múltiples connexions, les reutilitza i gestiona reconnexions automàtiques. En un servidor HTTP amb peticions concurrents, una connexió única seria un coll d’ampolla.RETURNINGen l’INSERT. En lloc de fer un INSERT i després un SELECT per obtenir l’ID i els timestamps generats, PostgreSQL els retorna en la mateixa query. Un sol anada i tornada al servidor en lloc de dos.RowsAffected()en UPDATE i DELETE. Si no s’ha afectat cap fila, el recurs no existeix. Això evita que un DELETE a un ID inexistent retorni 200 OK com si hagués esborrat alguna cosa.- Paràmetres
$1, $2, $3en lloc de concatenació de strings. Això prevé SQL injection. Mai, sota cap circumstància, construeixis queries ambfmt.Sprintf.
Capa de servei: lògica de negoci
El servei és la capa que orquestra. Rep peticions del handler, valida, crida el repositori i transforma errors de l’emmagatzematge en errors de domini.
// internal/service/task.go
package service
import (
"context"
"strings"
"github.com/el-teu-usuari/task-api/internal/apperror"
"github.com/el-teu-usuari/task-api/internal/model"
"github.com/el-teu-usuari/task-api/internal/repository"
)
type TaskService struct {
repo repository.TaskRepository
}
func NewTaskService(repo repository.TaskRepository) *TaskService {
return &TaskService{repo: repo}
}
func (s *TaskService) GetAll(ctx context.Context) ([]model.Task, error) {
tasks, err := s.repo.GetAll(ctx)
if err != nil {
return nil, apperror.NewInternal("failed to retrieve tasks", err)
}
return tasks, nil
}
func (s *TaskService) GetByID(ctx context.Context, id int) (*model.Task, error) {
task, err := s.repo.GetByID(ctx, id)
if err != nil {
return nil, apperror.NewNotFound("task not found")
}
return task, nil
}
func (s *TaskService) Create(ctx context.Context, req model.CreateTaskRequest) (*model.Task, error) {
title := strings.TrimSpace(req.Title)
if title == "" {
return nil, apperror.NewValidation("title is required")
}
if len(title) > 255 {
return nil, apperror.NewValidation("title must be 255 characters or less")
}
task := &model.Task{
Title: title,
Description: strings.TrimSpace(req.Description),
Done: false,
}
if err := s.repo.Create(ctx, task); err != nil {
return nil, apperror.NewInternal("failed to create task", err)
}
return task, nil
}
func (s *TaskService) Update(ctx context.Context, id int, req model.UpdateTaskRequest) (*model.Task, error) {
task, err := s.repo.GetByID(ctx, 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")
}
if len(title) > 255 {
return nil, apperror.NewValidation("title must be 255 characters or less")
}
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(ctx, task); err != nil {
return nil, apperror.NewInternal("failed to update task", err)
}
return task, nil
}
func (s *TaskService) Delete(ctx context.Context, id int) error {
_, err := s.repo.GetByID(ctx, id)
if err != nil {
return apperror.NewNotFound("task not found")
}
if err := s.repo.Delete(ctx, id); err != nil {
return apperror.NewInternal("failed to delete task", err)
}
return nil
}El servei rep la interfície TaskRepository, no la implementació de PostgreSQL. Això és el que permet que els tests unitaris funcionin amb un mock sense necessitar una base de dades. No és sobreenginyeria: és testabilitat bàsica.
Les validacions viuen aquí, no al handler. Si demà afegeixes un CLI que crea tasques, o un consumer de Kafka, les validacions seguiran aplicant-se perquè estan al servei, no lligades a HTTP.
Els errors del repositori es transformen en AppError. El handler mai veu un fmt.Errorf("task with id %d not found") del repo. Veu un apperror.NotFound, i sap exactament quin codi HTTP retornar.
Handlers HTTP amb Gin
Els handlers són la frontera entre HTTP i el teu domini. La seva feina és simple: parsejar la petició, cridar el servei, retornar la resposta. Qualsevol lògica de negoci aquí és un senyal que alguna cosa està al lloc equivocat.
// internal/handler/task.go
package handler
import (
"errors"
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"github.com/el-teu-usuari/task-api/internal/apperror"
"github.com/el-teu-usuari/task-api/internal/model"
"github.com/el-teu-usuari/task-api/internal/service"
)
type TaskHandler struct {
service *service.TaskService
}
func NewTaskHandler(service *service.TaskService) *TaskHandler {
return &TaskHandler{service: service}
}
func (h *TaskHandler) handleError(c *gin.Context, 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
}
c.JSON(status, gin.H{"error": appErr.Message})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "internal server error"})
}
func (h *TaskHandler) GetAll(c *gin.Context) {
tasks, err := h.service.GetAll(c.Request.Context())
if err != nil {
h.handleError(c, err)
return
}
c.JSON(http.StatusOK, tasks)
}
func (h *TaskHandler) GetByID(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
return
}
task, err := h.service.GetByID(c.Request.Context(), id)
if err != nil {
h.handleError(c, err)
return
}
c.JSON(http.StatusOK, task)
}
func (h *TaskHandler) Create(c *gin.Context) {
var req model.CreateTaskRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"})
return
}
task, err := h.service.Create(c.Request.Context(), req)
if err != nil {
h.handleError(c, err)
return
}
c.JSON(http.StatusCreated, task)
}
func (h *TaskHandler) Update(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
return
}
var req model.UpdateTaskRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"})
return
}
task, err := h.service.Update(c.Request.Context(), id, req)
if err != nil {
h.handleError(c, err)
return
}
c.JSON(http.StatusOK, task)
}
func (h *TaskHandler) Delete(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
return
}
if err := h.service.Delete(c.Request.Context(), id); err != nil {
h.handleError(c, err)
return
}
c.Status(http.StatusNoContent)
}
func RegisterRoutes(r *gin.Engine, h *TaskHandler) {
api := r.Group("/api")
{
api.GET("/tasks", h.GetAll)
api.GET("/tasks/:id", h.GetByID)
api.POST("/tasks", h.Create)
api.PUT("/tasks/:id", h.Update)
api.DELETE("/tasks/:id", h.Delete)
}
}Fixa’t en c.Request.Context(). Gin té el seu propi context, però el servei i el repositori esperen un context.Context estàndard. Passar el context de la petició HTTP significa que si el client tanca la connexió, la cancel·lació es propaga fins a la query de PostgreSQL. Sense això, les queries continuen executant-se tot i que ningú esperi la resposta.
ShouldBindJSON és de Gin i combina el json.Decode amb la validació dels tags binding. Si el camp title no està al body del POST, retorna error abans que el servei el vegi.
handleError centralitza la traducció de AppError a codi HTTP. Un sol punt on canvia la lògica si necessites afegir un nou tipus d’error.
Si vols entendre millor com funciona Gin i els seus middlewares, tinc un article sobre el framework Gin en Go.
Configuració amb variables d’entorn
Res hardcodejat. Port, URL de base de dades, timeouts: tot ve de l’entorn. Això fa que el mateix binari funcioni en desenvolupament, staging i producció sense recompilar.
// internal/config/config.go
package config
import (
"fmt"
"os"
"strconv"
"time"
)
type Config struct {
Port int
DatabaseURL string
MigrationsPath string
ReadTimeout time.Duration
WriteTimeout time.Duration
ShutdownTimeout time.Duration
}
func Load() (*Config, error) {
dbURL := os.Getenv("DATABASE_URL")
if dbURL == "" {
return nil, fmt.Errorf("DATABASE_URL is required")
}
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)
}
migrationsPath := os.Getenv("MIGRATIONS_PATH")
if migrationsPath == "" {
migrationsPath = "migrations"
}
return &Config{
Port: port,
DatabaseURL: dbURL,
MigrationsPath: migrationsPath,
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)
}DATABASE_URL no té valor per defecte. Si no està definida, l’aplicació no arrenca. És millor fallar ràpid amb un missatge clar que arrancar i fallar després amb un “connection refused” críptic.
El fitxer .env.example serveix com a documentació:
# .env.example
PORT=8080
DATABASE_URL=postgres://taskuser:taskpass@localhost:5432/taskdb?sslmode=disable
MIGRATIONS_PATH=migrations
READ_TIMEOUT=5s
WRITE_TIMEOUT=10s
SHUTDOWN_TIMEOUT=15sDocker: Dockerfile i docker-compose
El Dockerfile
# Build stage
FROM golang:1.23-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o /server ./cmd/server
# Run stage
FROM alpine:3.20
RUN apk --no-cache add ca-certificates
WORKDIR /app
COPY --from=builder /server .
COPY migrations/ ./migrations/
EXPOSE 8080
CMD ["./server"]Multi-stage build. La imatge final no té el compilador de Go, ni el codi font, ni les dependències de build. Només el binari i les migracions. Resultat: una imatge d’uns 15-20 MB en lloc dels 800+ MB de la imatge de Go.
CGO_ENABLED=0 és important. Sense ell, el binari podria dependre de llibreries C del sistema que no estan a Alpine. Amb CGO deshabilitat, el binari és completament estàtic.
Copiem migrations/ perquè l’aplicació les executa en arrencar. Si les migracions les aplica un procés extern, pots eliminar aquesta línia.
Si vols aprofundir en les decisions darrere de dockeritzar una API en Go, tinc un article dedicat.
docker-compose
# docker-compose.yml
services:
db:
image: postgres:16-alpine
environment:
POSTGRES_USER: taskuser
POSTGRES_PASSWORD: taskpass
POSTGRES_DB: taskdb
ports:
- "5432:5432"
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U taskuser -d taskdb"]
interval: 5s
timeout: 5s
retries: 5
api:
build: .
ports:
- "8080:8080"
environment:
PORT: 8080
DATABASE_URL: postgres://taskuser:taskpass@db:5432/taskdb?sslmode=disable
MIGRATIONS_PATH: migrations
depends_on:
db:
condition: service_healthy
volumes:
pgdata:El healthcheck a PostgreSQL és crucial. Sense ell, depends_on només garanteix que el contenidor ha arrencat, no que PostgreSQL estigui acceptant connexions. L’API intentaria connectar abans que la base de dades estigués llesta i fallaria. Amb condition: service_healthy, Docker espera que pg_isready retorni èxit.
El volum pgdata persisteix les dades entre reinicis dels contenidors. Sense ell, cada docker-compose down && docker-compose up comença amb una base de dades buida. Útil per a desenvolupament si ho vols, però incòmode si no.
El punt d’entrada: main.go
// cmd/server/main.go
package main
import (
"context"
"fmt"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"github.com/gin-gonic/gin"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/el-teu-usuari/task-api/internal/config"
"github.com/el-teu-usuari/task-api/internal/handler"
"github.com/el-teu-usuari/task-api/internal/repository"
"github.com/el-teu-usuari/task-api/internal/service"
)
func main() {
cfg, err := config.Load()
if err != nil {
log.Fatalf("failed to load config: %v", err)
}
// Pool de connexions a la base de dades
ctx := context.Background()
pool, err := pgxpool.New(ctx, cfg.DatabaseURL)
if err != nil {
log.Fatalf("failed to create connection pool: %v", err)
}
defer pool.Close()
if err := pool.Ping(ctx); err != nil {
log.Fatalf("failed to ping database: %v", err)
}
log.Println("connected to database")
// Executar migracions
if err := config.RunMigrations(cfg.DatabaseURL, cfg.MigrationsPath); err != nil {
log.Fatalf("failed to run migrations: %v", err)
}
log.Println("migrations applied")
// Cablejat de dependències
taskRepo := repository.NewPostgresTaskRepository(pool)
taskService := service.NewTaskService(taskRepo)
taskHandler := handler.NewTaskHandler(taskService)
// Configurar el router
r := gin.Default()
handler.RegisterRoutes(r, taskHandler)
// Servidor HTTP
srv := &http.Server{
Addr: fmt.Sprintf(":%d", cfg.Port),
Handler: r,
ReadTimeout: cfg.ReadTimeout,
WriteTimeout: cfg.WriteTimeout,
}
// Graceful shutdown
go func() {
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("server error: %v", err)
}
}()
log.Printf("server started on port %d", cfg.Port)
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
log.Println("shutting down server...")
ctx, cancel := context.WithTimeout(context.Background(), cfg.ShutdownTimeout)
defer cancel()
if err := srv.Shutdown(ctx); err != nil {
log.Fatalf("server forced to shutdown: %v", err)
}
log.Println("server stopped")
}El flux és lineal: carregar configuració, connectar a la base de dades, executar migracions, cablar dependències, arrencar el servidor, esperar senyal d’apagada.
El graceful shutdown és important. Quan Kubernetes o Docker envien un SIGTERM, el servidor deixa d’acceptar connexions noves però acaba les que estan en curs. Sense això, les peticions en vol es tallen enmig d’una transacció.
La injecció de dependències és manual: repo -> service -> handler. No necessites un framework de DI per a això. Quatre línies de codi, i cada component rep exactament el que necessita.
Testing: unitaris i integració
Aquí és on la separació per capes paga dividends. Els tests unitaris del servei no necessiten base de dades. Els tests d’integració del repositori fan servir un PostgreSQL real dins d’un contenidor.
Tests unitaris del servei
Per testar el servei sense base de dades, necessitem un mock del repositori. La interfície TaskRepository ens ho permet.
// internal/service/task_test.go
package service
import (
"context"
"fmt"
"testing"
"time"
"github.com/el-teu-usuari/task-api/internal/model"
)
// Repositori mock
type mockTaskRepo struct {
tasks map[int]*model.Task
nextID int
}
func newMockTaskRepo() *mockTaskRepo {
return &mockTaskRepo{
tasks: make(map[int]*model.Task),
nextID: 1,
}
}
func (m *mockTaskRepo) GetAll(ctx context.Context) ([]model.Task, error) {
var tasks []model.Task
for _, t := range m.tasks {
tasks = append(tasks, *t)
}
return tasks, nil
}
func (m *mockTaskRepo) GetByID(ctx context.Context, id int) (*model.Task, error) {
t, exists := m.tasks[id]
if !exists {
return nil, fmt.Errorf("not found")
}
return t, nil
}
func (m *mockTaskRepo) Create(ctx context.Context, task *model.Task) error {
task.ID = m.nextID
task.CreatedAt = time.Now()
task.UpdatedAt = time.Now()
m.tasks[task.ID] = task
m.nextID++
return nil
}
func (m *mockTaskRepo) Update(ctx context.Context, task *model.Task) error {
if _, exists := m.tasks[task.ID]; !exists {
return fmt.Errorf("not found")
}
task.UpdatedAt = time.Now()
m.tasks[task.ID] = task
return nil
}
func (m *mockTaskRepo) Delete(ctx context.Context, id int) error {
if _, exists := m.tasks[id]; !exists {
return fmt.Errorf("not found")
}
delete(m.tasks, id)
return nil
}
func TestCreateTask(t *testing.T) {
repo := newMockTaskRepo()
svc := NewTaskService(repo)
ctx := context.Background()
t.Run("creates task successfully", func(t *testing.T) {
req := model.CreateTaskRequest{
Title: "Buy groceries",
Description: "Milk, eggs, bread",
}
task, err := svc.Create(ctx, req)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if task.Title != "Buy groceries" {
t.Errorf("expected title 'Buy groceries', got '%s'", task.Title)
}
if task.Done {
t.Error("new task should not be done")
}
if task.ID == 0 {
t.Error("expected non-zero ID")
}
})
t.Run("rejects empty title", func(t *testing.T) {
req := model.CreateTaskRequest{
Title: " ",
}
_, err := svc.Create(ctx, req)
if err == nil {
t.Fatal("expected error for empty title")
}
})
t.Run("rejects title over 255 characters", func(t *testing.T) {
longTitle := ""
for i := 0; i < 256; i++ {
longTitle += "a"
}
req := model.CreateTaskRequest{
Title: longTitle,
}
_, err := svc.Create(ctx, req)
if err == nil {
t.Fatal("expected error for long title")
}
})
}
func TestUpdateTask(t *testing.T) {
repo := newMockTaskRepo()
svc := NewTaskService(repo)
ctx := context.Background()
// Creem una tasca primer
created, _ := svc.Create(ctx, model.CreateTaskRequest{
Title: "Original title",
})
t.Run("updates only provided fields", func(t *testing.T) {
newTitle := "Updated title"
req := model.UpdateTaskRequest{
Title: &newTitle,
}
updated, err := svc.Update(ctx, created.ID, req)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if updated.Title != "Updated title" {
t.Errorf("expected title 'Updated title', got '%s'", updated.Title)
}
if updated.Done {
t.Error("done should not have changed")
}
})
t.Run("returns not found for non-existent task", func(t *testing.T) {
newTitle := "whatever"
req := model.UpdateTaskRequest{Title: &newTitle}
_, err := svc.Update(ctx, 9999, req)
if err == nil {
t.Fatal("expected not found error")
}
})
}
func TestDeleteTask(t *testing.T) {
repo := newMockTaskRepo()
svc := NewTaskService(repo)
ctx := context.Background()
created, _ := svc.Create(ctx, model.CreateTaskRequest{
Title: "To be deleted",
})
t.Run("deletes existing task", func(t *testing.T) {
err := svc.Delete(ctx, created.ID)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
_, err = svc.GetByID(ctx, created.ID)
if err == nil {
t.Fatal("expected not found after delete")
}
})
t.Run("returns not found for non-existent task", func(t *testing.T) {
err := svc.Delete(ctx, 9999)
if err == nil {
t.Fatal("expected not found error")
}
})
}El mock implementa la interfície TaskRepository amb un simple mapa en memòria. No fem servir mockgen ni testify/mock: per a una interfície amb cinc mètodes, un mock manual és més clar i no afegeix dependències.
Els tests són directes: verifiquen que el servei valida correctament, crea tasques amb els camps esperats, actualitza només els camps enviats i retorna errors per a recursos inexistents. Si vols aprofundir en patrons de testing en Go, tinc un article complet.
Executa’ls amb:
go test ./internal/service/ -vTests d’integració amb testcontainers
Els tests unitaris validen la lògica de negoci. Els tests d’integració validen que el SQL funciona contra un PostgreSQL real. Aquí és on testcontainers brilla: aixeca un contenidor de PostgreSQL només per als tests i el destrueix en acabar.
// internal/repository/task_test.go
package repository
import (
"context"
"fmt"
"testing"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/testcontainers/testcontainers-go"
"github.com/testcontainers/testcontainers-go/modules/postgres"
"github.com/testcontainers/testcontainers-go/wait"
"github.com/el-teu-usuari/task-api/internal/config"
"github.com/el-teu-usuari/task-api/internal/model"
)
func setupTestDB(t *testing.T) (*pgxpool.Pool, func()) {
t.Helper()
ctx := context.Background()
pgContainer, err := postgres.Run(ctx,
"postgres:16-alpine",
postgres.WithDatabase("testdb"),
postgres.WithUsername("testuser"),
postgres.WithPassword("testpass"),
testcontainers.WithWaitStrategy(
wait.ForLog("database system is ready to accept connections").
WithOccurrence(2).
WithStartupTimeout(30*time.Second),
),
)
if err != nil {
t.Fatalf("failed to start postgres container: %v", err)
}
connStr, err := pgContainer.ConnectionString(ctx, "sslmode=disable")
if err != nil {
t.Fatalf("failed to get connection string: %v", err)
}
// Executar migracions
if err := config.RunMigrations(connStr, "../../migrations"); err != nil {
t.Fatalf("failed to run migrations: %v", err)
}
pool, err := pgxpool.New(ctx, connStr)
if err != nil {
t.Fatalf("failed to create pool: %v", err)
}
cleanup := func() {
pool.Close()
if err := pgContainer.Terminate(ctx); err != nil {
t.Logf("failed to terminate container: %v", err)
}
}
return pool, cleanup
}
func TestPostgresTaskRepository_CRUD(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test")
}
pool, cleanup := setupTestDB(t)
defer cleanup()
repo := NewPostgresTaskRepository(pool)
ctx := context.Background()
// Crear
task := &model.Task{
Title: "Integration test task",
Description: "Testing against real PostgreSQL",
Done: false,
}
err := repo.Create(ctx, task)
if err != nil {
t.Fatalf("Create failed: %v", err)
}
if task.ID == 0 {
t.Fatal("expected non-zero ID after create")
}
if task.CreatedAt.IsZero() {
t.Fatal("expected non-zero created_at")
}
// GetByID
fetched, err := repo.GetByID(ctx, task.ID)
if err != nil {
t.Fatalf("GetByID failed: %v", err)
}
if fetched.Title != "Integration test task" {
t.Errorf("expected title 'Integration test task', got '%s'", fetched.Title)
}
// Update
fetched.Title = "Updated integration task"
fetched.Done = true
err = repo.Update(ctx, fetched)
if err != nil {
t.Fatalf("Update failed: %v", err)
}
updated, _ := repo.GetByID(ctx, fetched.ID)
if updated.Title != "Updated integration task" {
t.Errorf("expected updated title, got '%s'", updated.Title)
}
if !updated.Done {
t.Error("expected task to be done")
}
// GetAll
tasks, err := repo.GetAll(ctx)
if err != nil {
t.Fatalf("GetAll failed: %v", err)
}
if len(tasks) != 1 {
t.Errorf("expected 1 task, got %d", len(tasks))
}
// Delete
err = repo.Delete(ctx, task.ID)
if err != nil {
t.Fatalf("Delete failed: %v", err)
}
_, err = repo.GetByID(ctx, task.ID)
if err == nil {
t.Fatal("expected error after delete")
}
}
func TestPostgresTaskRepository_NotFound(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test")
}
pool, cleanup := setupTestDB(t)
defer cleanup()
repo := NewPostgresTaskRepository(pool)
ctx := context.Background()
_, err := repo.GetByID(ctx, 99999)
if err == nil {
t.Fatal("expected error for non-existent task")
}
err = repo.Delete(ctx, 99999)
if err == nil {
t.Fatal("expected error deleting non-existent task")
}
}Punts importants:
testing.Short()permet saltar els tests d’integració ambgo test -short. Els tests unitaris corren en mil·lisegons; els d’integració necessiten aixecar un contenidor de Docker, que triga uns segons. En CI voldràs córrer ambdós, però en desenvolupament local de vegades només vols els ràpids.setupTestDBaixeca un PostgreSQL real i executa les migracions. Cada test té una base de dades neta. No hi ha estat compartit entre tests.WithOccurrence(2)en el wait strategy és perquè PostgreSQL registra “ready to accept connections” dues vegades: una en arrencar i una altra després de la inicialització. Sense elWithOccurrence(2), el test podria intentar connectar abans que PostgreSQL estigui realment llest.- El cleanup tanca el pool i destrueix el contenidor. Testcontainers s’encarrega de netejar, però ser explícit no fa cap mal.
Executar els tests d’integració:
go test ./internal/repository/ -vPer a només els unitaris:
go test ./internal/service/ -v -shortAixecant-ho tot
Desenvolupament local
Opció 1: Només la base de dades en Docker, l’API en local.
# docker-compose.dev.yml
services:
db:
image: postgres:16-alpine
environment:
POSTGRES_USER: taskuser
POSTGRES_PASSWORD: taskpass
POSTGRES_DB: taskdb
ports:
- "5432:5432"
volumes:
- pgdata:/var/lib/postgresql/data
volumes:
pgdata:docker-compose -f docker-compose.dev.yml up -d
DATABASE_URL="postgres://taskuser:taskpass@localhost:5432/taskdb?sslmode=disable" go run ./cmd/serverOpció 2: Tot en Docker.
docker-compose up --buildVerificar que funciona
Crear una tasca:
curl -X POST http://localhost:8080/api/tasks \
-H "Content-Type: application/json" \
-d '{"title": "Learn Go", "description": "Build a real project"}'Llistar tasques:
curl http://localhost:8080/api/tasksActualitzar:
curl -X PUT http://localhost:8080/api/tasks/1 \
-H "Content-Type: application/json" \
-d '{"done": true}'Eliminar:
curl -X DELETE http://localhost:8080/api/tasks/1Si tot va bé, reps els codis HTTP correctes: 201 en crear, 200 en llistar i actualitzar, 204 en eliminar, 404 si l’ID no existeix, 400 si el body és invàlid.
Executar tots els tests
# Només unitaris
go test ./... -short -v
# Tot, incloent integració (necessita Docker)
go test ./... -vUn template de decisions, no només una API de tasques
Això no és només una API de tasques. És un template de decisions que s’apliquen a qualsevol API en Go amb PostgreSQL. La separació per capes permet testar cada peça de forma aïllada: el servei no sap de HTTP, el repositori no sap de lògica de negoci. Les migracions versionades amb golang-migrate viatgen amb el codi i integrades en l’arrencada. Els tests unitaris amb mocks manuals no necessiten infraestructura, mentre que els d’integració amb testcontainers validen el SQL contra PostgreSQL real. Docker multi-stage produeix imatges petites, docker-compose amb healthchecks garanteix l’ordre d’arrencada, la configuració per entorn desacobla el binari del desplegament, i el graceful shutdown no talla peticions en vol.
El que falta són extensions naturals: paginació (GET /api/tasks?page=1&limit=20) per no retornar milers de registres de cop, logging estructurat amb slog o zerolog per tenir JSON amb nivells i camps contextuals, middleware d’autenticació amb JWT o API keys aprofitant el sistema de middleware de Gin, CI/CD amb GitHub Actions per executar els tests a cada push, i observabilitat amb Prometheus i OpenTelemetry per passar d’una API que funciona a una que pots operar.
El codi complet d’aquest projecte està pensat perquè el copiïs, el modifiquis i el facis servir com a base. Canvia Task pel teu domini, afegeix les capes que necessitis, i tens un punt de partida sòlid per a qualsevol API en Go.


