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.

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étodo | Ruta | Descripción |
|---|---|---|
GET | /api/tasks | Listar todas las tareas |
GET | /api/tasks/{id} | Obtener una tarea por ID |
POST | /api/tasks | Crear 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.sumSi 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-apiDependencias 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/postgresBase 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:
SERIALen lugar deBIGSERIAL: Para una tabla de tareas, unint4que soporta hasta 2 mil millones de filas es más que suficiente. No usesBIGSERIALpor defecto; gasta el doble de espacio en índices sin necesidad.TIMESTAMPTZen lugar deTIMESTAMP: 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 conNULLen strings. Un string vacío es más fácil de manejar en Go que un*stringnullable.- Í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.RETURNINGen 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, $3en lugar de concatenación de strings. Esto previene SQL injection. Nunca, bajo ninguna circunstancia, construyas queries confmt.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=15sDocker: 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/ -vTests 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 congo 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.setupTestDBlevanta 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 elWithOccurrence(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/ -vPara solo los unitarios:
go test ./internal/service/ -v -shortLevantando 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/serverOpción 2: Todo en Docker.
docker-compose up --buildVerificar 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/tasksActualizar:
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 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 ./... -vUn 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.


