Crear una API de tareas con Go, PostgreSQL y Docker

Proyecto completo: API REST en Go con PostgreSQL, Docker, migraciones, tests y estructura limpia. De cero a despliegue.

Cover for Crear una API de tareas con Go, PostgreSQL y Docker

Otra API de tareas. Lo sé. Pero la mayoría de tutoriales se quedan en un CRUD contra una base de datos hardcodeada, sin migraciones, sin tests de integración, sin Docker, y con toda la lógica metida en main.go. Cuando acabas tienes algo que funciona pero que no te enseña nada sobre cómo se construye software de verdad.

Este proyecto es diferente. Vamos a construir lo mismo (una API de tareas) pero tomando las decisiones que tomarías en un proyecto real: estructura por capas, migraciones con golang-migrate, repositorio contra PostgreSQL, tests unitarios con mocks, tests de integración con testcontainers, configuración por variables de entorno, y Docker para que cualquiera pueda levantar el proyecto con un solo comando.

El CRUD es el vehículo. Las decisiones de arquitectura, testing y despliegue son el destino.

Si vienes del artículo de API REST con Go donde usamos almacenamiento en memoria, esto es la evolución natural: conectar con una base de datos real y preparar el proyecto para producción.


Qué vamos a construir

Una API REST con estos endpoints:

MétodoRutaDescripción
GET/api/tasksListar todas las tareas
GET/api/tasks/{id}Obtener una tarea por ID
POST/api/tasksCrear una nueva tarea
PUT/api/tasks/{id}Actualizar una tarea
DELETE/api/tasks/{id}Eliminar una tarea

Y estas piezas:

  • Go + Gin como framework HTTP
  • PostgreSQL como base de datos
  • golang-migrate para migraciones de esquema
  • pgx como driver de PostgreSQL (no el database/sql genérico)
  • testcontainers-go para tests de integración reales
  • Docker + docker-compose para levantar todo el entorno

No vamos a usar ORMs. Si quieres entender cómo funciona la interacción con la base de datos, necesitas escribir SQL. Un ORM te oculta exactamente las partes que necesitas dominar.


Estructura del proyecto

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 leído el artículo sobre estructura de proyecto, esto te resultará familiar. cmd/ para puntos de entrada, internal/ para código privado del módulo, paquetes por responsabilidad. Lo nuevo aquí es migrations/ para los archivos SQL de golang-migrate.

Inicializamos:

go mod init github.com/tu-usuario/task-api

Dependencias que necesitaremos:

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 datos: esquema y migraciones con golang-migrate

Antes de escribir Go, definimos la base de datos. El esquema es sencillo pero tiene lo que importa: tipos correctos, timestamps automáticos, y un índice que tiene sentido.

La migración

-- 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;

Decisiones que importan:

  • SERIAL en lugar de BIGSERIAL: Para una tabla de tareas, un int4 que soporta hasta 2 mil millones de filas es más que suficiente. No uses BIGSERIAL por defecto; gasta el doble de espacio en índices sin necesidad.
  • TIMESTAMPTZ en lugar de TIMESTAMP: Siempre con zona horaria. Sin ella, cada aplicación interpreta las fechas como le parece. En producción esto causa bugs sutiles y dolorosos.
  • DEFAULT '' en description: Evita tener que lidiar con NULL en strings. Un string vacío es más fácil de manejar en Go que un *string nullable.
  • Índice en done: Si el caso de uso principal es “dame las tareas pendientes”, este índice acelera la consulta más común. No es que sea obligatorio para un proyecto pequeño, pero es el tipo de decisión que deberías tomar desde el principio.

Aplicar migraciones desde Go

Podrías ejecutar las migraciones desde la línea de comandos con el CLI de golang-migrate, pero integrarlas en el arranque de la aplicación tiene ventajas: el servidor no arranca si la base de datos no está al día, y no dependes de que alguien recuerde ejecutar un comando antes del deploy.

// 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 es un error real: significa que las migraciones ya están aplicadas. Si lo tratas como error, el servidor falla cada reinicio después del primero.

Si quieres profundizar en la conexión y el manejo de PostgreSQL con Go, tengo un artículo dedicado donde cubrimos pgx en detalle.


Modelos y capa de repositorio

El modelo de dominio

// 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"`
}

Los punteros en UpdateTaskRequest son clave. Sin ellos no puedes distinguir entre “el usuario no envió este campo” y “el usuario envió un valor vacío”. Es la diferencia entre un PATCH real y un PUT disfrazado. Si vienes de lenguajes con Optional o null, los punteros son el equivalente en Go.

El tag binding:"required" es de Gin. Valida que el campo exista en el JSON antes de que llegue al servicio.

La interfaz del repositorio

// internal/repository/task.go
package repository

import (
	"context"

	"github.com/tu-usuario/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
}

Diferencia crítica respecto al artículo de API REST con Go donde usábamos un repositorio en memoria: aquí todos los métodos reciben context.Context. Cuando hablas con una base de datos, necesitas contexto para timeouts y cancelaciones. Si una petición HTTP se cancela, el contexto se propaga hasta la query y PostgreSQL deja de trabajar. Sin contexto, la query sigue ejecutándose aunque nadie espere el resultado.

La implementación con 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/tu-usuario/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
}

Cosas que notar:

  • pgxpool.Pool, no una conexión simple. El pool gestiona múltiples conexiones, las reutiliza, y maneja reconexiones automáticas. En un servidor HTTP con peticiones concurrentes, una conexión única sería un cuello de botella.
  • RETURNING en el INSERT. En vez de hacer un INSERT y luego un SELECT para obtener el ID y los timestamps generados, PostgreSQL te los devuelve en la misma query. Una sola ida y vuelta al servidor en lugar de dos.
  • RowsAffected() en UPDATE y DELETE. Si no se afectó ninguna fila, el recurso no existe. Esto evita que un DELETE a un ID inexistente devuelva 200 OK como si hubiera borrado algo.
  • Parámetros $1, $2, $3 en lugar de concatenación de strings. Esto previene SQL injection. Nunca, bajo ninguna circunstancia, construyas queries con fmt.Sprintf.

Capa de servicio: lógica de negocio

El servicio es la capa que orquesta. Recibe peticiones del handler, valida, llama al repositorio, y transforma errores del storage en errores de dominio.

// internal/service/task.go
package service

import (
	"context"
	"strings"

	"github.com/tu-usuario/task-api/internal/apperror"
	"github.com/tu-usuario/task-api/internal/model"
	"github.com/tu-usuario/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 servicio recibe la interfaz TaskRepository, no la implementación de PostgreSQL. Esto es lo que permite que los tests unitarios funcionen con un mock sin necesitar una base de datos. No es sobreingeniería: es testabilidad básica.

Las validaciones viven aquí, no en el handler. Si mañana añades un CLI que crea tareas, o un consumer de Kafka, las validaciones siguen aplicando porque están en el servicio, no atadas a HTTP.

Los errores del repositorio se transforman en AppError. El handler nunca ve un fmt.Errorf("task with id %d not found") del repo. Ve un apperror.NotFound, y sabe exactamente qué código HTTP devolver.


Handlers HTTP con Gin

Los handlers son la frontera entre HTTP y tu dominio. Su trabajo es simple: parsear la petición, llamar al servicio, devolver la respuesta. Cualquier lógica de negocio aquí es una señal de que algo está en el sitio equivocado.

// internal/handler/task.go
package handler

import (
	"errors"
	"net/http"
	"strconv"

	"github.com/gin-gonic/gin"
	"github.com/tu-usuario/task-api/internal/apperror"
	"github.com/tu-usuario/task-api/internal/model"
	"github.com/tu-usuario/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)
	}
}

Fíjate en c.Request.Context(). Gin tiene su propio contexto, pero el servicio y el repositorio esperan un context.Context estándar. Pasar el contexto de la petición HTTP significa que si el cliente cierra la conexión, la cancelación se propaga hasta la query de PostgreSQL. Sin esto, las queries siguen ejecutándose aunque nadie espere la respuesta.

ShouldBindJSON es de Gin y combina el json.Decode con la validación de los tags binding. Si el campo title no está en el body del POST, devuelve error antes de que el servicio lo vea.

handleError centraliza la traducción de AppError a código HTTP. Un solo punto donde cambia la lógica si necesitas añadir un nuevo tipo de error.

Si quieres entender mejor cómo funciona Gin y sus middlewares, tengo un artículo sobre el framework Gin en Go.


Configuración con variables de entorno

Nada hardcodeado. Puerto, URL de base de datos, timeouts: todo viene del entorno. Esto hace que el mismo binario sirva en desarrollo, staging y producción sin 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 tiene valor por defecto. Si no está definida, la aplicación no arranca. Es mejor fallar rápido con un mensaje claro que arrancar y fallar después con un “connection refused” críptico.

El archivo .env.example sirve como documentación:

# .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 y 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 imagen final no tiene el compilador de Go, ni el código fuente, ni las dependencias de build. Solo el binario y las migraciones. Resultado: una imagen de unos 15-20 MB en lugar de los 800+ MB de la imagen de Go.

CGO_ENABLED=0 es importante. Sin ello, el binario podría depender de librerías C del sistema que no están en Alpine. Con CGO deshabilitado, el binario es completamente estático.

Copiamos migrations/ porque la aplicación las ejecuta al arrancar. Si las migraciones las aplica un proceso externo, puedes eliminar esa línea.

Si quieres profundizar en las decisiones detrás de dockerizar una API en Go, tengo un artículo dedicado.

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 en PostgreSQL es crucial. Sin él, depends_on solo garantiza que el contenedor ha arrancado, no que PostgreSQL esté aceptando conexiones. La API intentaría conectar antes de que la base de datos esté lista y fallaría. Con condition: service_healthy, Docker espera a que pg_isready devuelva éxito.

El volumen pgdata persiste los datos entre reinicios de los contenedores. Sin él, cada docker-compose down && docker-compose up empieza con una base de datos vacía. Útil para desarrollo si lo quieres, pero incómodo si no.


El punto de 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/tu-usuario/task-api/internal/config"
	"github.com/tu-usuario/task-api/internal/handler"
	"github.com/tu-usuario/task-api/internal/repository"
	"github.com/tu-usuario/task-api/internal/service"
)

func main() {
	cfg, err := config.Load()
	if err != nil {
		log.Fatalf("failed to load config: %v", err)
	}

	// Database connection pool
	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")

	// Run migrations
	if err := config.RunMigrations(cfg.DatabaseURL, cfg.MigrationsPath); err != nil {
		log.Fatalf("failed to run migrations: %v", err)
	}
	log.Println("migrations applied")

	// Wire dependencies
	taskRepo := repository.NewPostgresTaskRepository(pool)
	taskService := service.NewTaskService(taskRepo)
	taskHandler := handler.NewTaskHandler(taskService)

	// Setup router
	r := gin.Default()
	handler.RegisterRoutes(r, taskHandler)

	// HTTP server
	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 flujo es lineal: cargar configuración, conectar a la base de datos, ejecutar migraciones, cablear dependencias, arrancar el servidor, esperar señal de apagado.

El graceful shutdown es importante. Cuando Kubernetes o Docker mandan un SIGTERM, el servidor deja de aceptar conexiones nuevas pero termina las que están en curso. Sin esto, las peticiones en vuelo se cortan en medio de una transacción.

La inyección de dependencias es manual: repo -> service -> handler. No necesitas un framework de DI para esto. Cuatro líneas de código, y cada componente recibe exactamente lo que necesita.


Testing: unitarios e integración

Aquí es donde la separación por capas paga dividendos. Los tests unitarios del servicio no necesitan base de datos. Los tests de integración del repositorio usan una PostgreSQL real dentro de un contenedor.

Tests unitarios del servicio

Para testear el servicio sin base de datos, necesitamos un mock del repositorio. La interfaz TaskRepository nos lo permite.

// internal/service/task_test.go
package service

import (
	"context"
	"fmt"
	"testing"
	"time"

	"github.com/tu-usuario/task-api/internal/model"
)

// Mock repository
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()

	// Create a task first
	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 interfaz TaskRepository con un simple mapa en memoria. No usamos mockgen ni testify/mock: para una interfaz con cinco métodos, un mock manual es más claro y no añade dependencias.

Los tests son directos: verifican que el servicio valida correctamente, crea tareas con los campos esperados, actualiza solo los campos enviados, y devuelve errores para recursos inexistentes. Si quieres profundizar en patrones de testing en Go, tengo un artículo completo.

Ejecútalos con:

go test ./internal/service/ -v

Tests de integración con testcontainers

Los tests unitarios validan la lógica de negocio. Los tests de integración validan que el SQL funciona contra una PostgreSQL real. Aquí es donde testcontainers brilla: levanta un contenedor de PostgreSQL solo para los tests y lo destruye al terminar.

// 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/tu-usuario/task-api/internal/config"
	"github.com/tu-usuario/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)
	}

	// Run migrations
	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()

	// Create
	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")
	}
}

Puntos importantes:

  • testing.Short() permite saltar los tests de integración con go test -short. Los tests unitarios corren en milisegundos; los de integración necesitan levantar un contenedor de Docker, que tarda unos segundos. En CI querrás correr ambos, pero en desarrollo local a veces solo quieres los rápidos.
  • setupTestDB levanta un PostgreSQL real y ejecuta las migraciones. Cada test tiene una base de datos limpia. No hay estado compartido entre tests.
  • WithOccurrence(2) en el wait strategy es porque PostgreSQL loguea “ready to accept connections” dos veces: una al arrancar y otra después de la inicialización. Sin el WithOccurrence(2), el test podría intentar conectar antes de que PostgreSQL esté realmente listo.
  • El cleanup cierra el pool y destruye el contenedor. Testcontainers se encarga de limpiar, pero ser explícito no hace daño.

Ejecutar los tests de integración:

go test ./internal/repository/ -v

Para solo los unitarios:

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

Levantando todo junto

Desarrollo local

Opción 1: Solo la base de datos en Docker, la 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ón 2: Todo en Docker.

docker-compose up --build

Verificar que funciona

Crear una tarea:

curl -X POST http://localhost:8080/api/tasks \
  -H "Content-Type: application/json" \
  -d '{"title": "Learn Go", "description": "Build a real project"}'

Listar tareas:

curl http://localhost:8080/api/tasks

Actualizar:

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 todo está bien, recibes los códigos HTTP correctos: 201 al crear, 200 al listar y actualizar, 204 al eliminar, 404 si el ID no existe, 400 si el body es inválido.

Ejecutar todos los tests

# Solo unitarios
go test ./... -short -v

# Todo, incluyendo integración (necesita Docker)
go test ./... -v

Un template de decisiones, no solo una API de tareas

Esto no es solo una API de tareas. Es un template de decisiones que aplican a cualquier API en Go con PostgreSQL. La separación por capas permite testear cada pieza de forma aislada: el servicio no sabe de HTTP, el repositorio no sabe de lógica de negocio. Las migraciones versionadas con golang-migrate viajan con el código e integradas en el arranque. Los tests unitarios con mocks manuales no necesitan infraestructura, mientras que los de integración con testcontainers validan el SQL contra PostgreSQL real. Docker multi-stage produce imágenes pequeñas, docker-compose con healthchecks garantiza el orden de arranque, la configuración por entorno desacopla el binario del despliegue, y el graceful shutdown no corta peticiones en vuelo.

Lo que falta son extensiones naturales: paginación (GET /api/tasks?page=1&limit=20) para no devolver miles de registros de golpe, logging estructurado con slog o zerolog para tener JSON con niveles y campos contextuales, middleware de autenticación con JWT o API keys aprovechando el sistema de middleware de Gin, CI/CD con GitHub Actions para ejecutar los tests en cada push, y observabilidad con Prometheus y OpenTelemetry para pasar de una API que funciona a una que puedes operar.

El código completo de este proyecto está pensado para que lo copies, lo modifiques, y lo uses como base. Cambia Task por tu dominio, añade las capas que necesites, y tienes un punto de partida sólido para cualquier API en Go.

OshyTech

Ingeniería backend y de datos orientada a sistemas escalables, automatización e IA.

Navegación

Copyright 2026 OshyTech. Todos los derechos reservados