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.

Cover for Crear una API de tasques amb Go, PostgreSQL i Docker

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ètodeRutaDescripció
GET/api/tasksLlistar totes les tasques
GET/api/tasks/{id}Obtenir una tasca per ID
POST/api/tasksCrear 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.sum

Si 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-api

Dependè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/postgres

Base 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:

  • SERIAL en lloc de BIGSERIAL: Per a una taula de tasques, un int4 que suporta fins a 2 mil milions de files és més que suficient. No facis servir BIGSERIAL per defecte; gasta el doble d’espai en índexs sense necessitat.
  • TIMESTAMPTZ en lloc de TIMESTAMP: 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 gestionar NULL en strings. Un string buit és més fàcil de gestionar en Go que un *string nullable.
  • Í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.
  • RETURNING en 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, $3 en lloc de concatenació de strings. Això prevé SQL injection. Mai, sota cap circumstància, construeixis queries amb fmt.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=15s

Docker: 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/ -v

Tests 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ó amb go 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.
  • setupTestDB aixeca 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 el WithOccurrence(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/ -v

Per a només els unitaris:

go test ./internal/service/ -v -short

Aixecant-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/server

Opció 2: Tot en Docker.

docker-compose up --build

Verificar 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/tasks

Actualitzar:

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/1

Si 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 ./... -v

Un 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.

OshyTech

Enginyeria backend i de dades orientada a sistemes escalables, automatització i IA.

Navegació

Copyright 2026 OshyTech. Tots els drets reservats