Crear una API REST con Go desde cero: estructura limpia y código mantenible
Tutorial para construir una API REST en Go con buena estructura: handlers, servicios, repositorios, errores y configuración.

Todos los tutoriales de Go construyen una API de tareas. Este también. Pero el objetivo aquí no es que funcione el CRUD: es que la estructura del proyecto tenga sentido cuando vuelvas a abrirlo una semana después y necesites añadir un endpoint sin romper tres archivos.
He visto demasiados proyectos en Go donde todo vive en main.go, los handlers acceden directamente a la base de datos, y la gestión de errores es un fmt.Println seguido de un http.Error con un mensaje genérico. Funciona, claro. Hasta que el proyecto crece y cada cambio se convierte en una sesión de arqueología.
Lo que vamos a construir es una API de tareas (TODO) con separación real entre capas: handlers para HTTP, servicios para lógica de negocio, repositorios para acceso a datos, modelos para el dominio, y un sistema de errores que no te haga adivinar qué ha fallado. No es una arquitectura enterprise, es lo mínimo que necesitas para que el código sea mantenible.
Estructura del proyecto
Antes de escribir una sola línea de código, la decisión más importante es cómo organizas los archivos. Go no te obliga a ninguna estructura concreta, lo cual es liberador y peligroso a partes iguales. Si quieres profundizar en esto, tengo un artículo dedicado a estructura de proyecto Go, pero para esta API vamos con algo directo.
todo-api/
├── cmd/
│ └── server/
│ └── main.go
├── internal/
│ ├── config/
│ │ └── config.go
│ ├── handler/
│ │ └── task.go
│ ├── model/
│ │ └── task.go
│ ├── repository/
│ │ └── task.go
│ ├── service/
│ │ └── task.go
│ └── apperror/
│ └── errors.go
├── go.mod
└── go.sumHay decisiones deliberadas aquí:
cmd/server/: El punto de entrada. Si mañana necesitas un CLI o un worker, añadescmd/cli/ycmd/worker/sin tocar nada.internal/: Todo lo que no debería ser importado desde fuera del módulo. Go lo fuerza a nivel de compilador, no es una convención opcional.- Paquetes por responsabilidad, no por feature:
handler,service,repository. Notask/handler.go,task/service.go. En un proyecto pequeño es más claro, y cuando crece, el refactor a feature-based es mecánico.
Inicializamos el módulo:
go mod init github.com/tu-usuario/todo-apiLos modelos: definir el dominio primero
Antes de pensar en HTTP o en bases de datos, necesitas saber qué es una tarea en tu dominio. No es un JSON, no es una fila de la base de datos. Es un tipo de Go con campos claros.
// internal/model/task.go
package model
import "time"
type Task struct {
ID int `json:"id"`
Title string `json:"title"`
Description string `json:"description"`
Done bool `json:"done"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type CreateTaskRequest struct {
Title string `json:"title"`
Description string `json:"description"`
}
type UpdateTaskRequest struct {
Title *string `json:"title"`
Description *string `json:"description"`
Done *bool `json:"done"`
}Hay decisiones importantes aquí:
Taskes el modelo de dominio. Tiene todos los campos, incluidos los que genera el sistema (ID,CreatedAt,UpdatedAt).CreateTaskRequestno tieneIDni timestamps. Esos campos no los pone el usuario. Separar el modelo de creación del modelo de dominio te evita bugs donde alguien manda uniden el body y espera que se respete.UpdateTaskRequestusa punteros. Un*stringpuede sernil, lo cual te permite distinguir entre “el usuario no mandó este campo” y “el usuario mandó un string vacío”. Sin punteros, no puedes hacer actualizaciones parciales correctamente.
Ese detalle de los punteros en el update es algo que muchos tutoriales ignoran, y cuando llegas a producción es de las primeras cosas que te muerden.
El repositorio: acceso a datos como abstracción
El repositorio es la capa que sabe cómo almacenar y recuperar tareas. Por ahora vamos a usar un almacenamiento en memoria. No porque sea útil en producción, sino porque desacoplar esta capa desde el principio significa que cambiar a PostgreSQL después es cambiar una implementación, no reescribir medio proyecto.
// internal/repository/task.go
package repository
import (
"fmt"
"sync"
"time"
"github.com/tu-usuario/todo-api/internal/model"
)
type TaskRepository interface {
GetAll() ([]model.Task, error)
GetByID(id int) (*model.Task, error)
Create(task *model.Task) error
Update(task *model.Task) error
Delete(id int) error
}
type InMemoryTaskRepository struct {
mu sync.RWMutex
tasks map[int]model.Task
nextID int
}
func NewInMemoryTaskRepository() *InMemoryTaskRepository {
return &InMemoryTaskRepository{
tasks: make(map[int]model.Task),
nextID: 1,
}
}La interfaz TaskRepository es el contrato. Cualquier implementación que cumpla esos cinco métodos sirve. Esto no es sobreingeniería: es lo que te permite escribir tests sin base de datos y cambiar de storage sin tocar la lógica de negocio.
Ahora las implementaciones concretas:
func (r *InMemoryTaskRepository) GetAll() ([]model.Task, error) {
r.mu.RLock()
defer r.mu.RUnlock()
tasks := make([]model.Task, 0, len(r.tasks))
for _, t := range r.tasks {
tasks = append(tasks, t)
}
return tasks, nil
}
func (r *InMemoryTaskRepository) GetByID(id int) (*model.Task, error) {
r.mu.RLock()
defer r.mu.RUnlock()
task, exists := r.tasks[id]
if !exists {
return nil, fmt.Errorf("task with id %d not found", id)
}
return &task, nil
}
func (r *InMemoryTaskRepository) Create(task *model.Task) error {
r.mu.Lock()
defer r.mu.Unlock()
task.ID = r.nextID
task.CreatedAt = time.Now()
task.UpdatedAt = time.Now()
r.tasks[task.ID] = *task
r.nextID++
return nil
}
func (r *InMemoryTaskRepository) Update(task *model.Task) error {
r.mu.Lock()
defer r.mu.Unlock()
if _, exists := r.tasks[task.ID]; !exists {
return fmt.Errorf("task with id %d not found", task.ID)
}
task.UpdatedAt = time.Now()
r.tasks[task.ID] = *task
return nil
}
func (r *InMemoryTaskRepository) Delete(id int) error {
r.mu.Lock()
defer r.mu.Unlock()
if _, exists := r.tasks[id]; !exists {
return fmt.Errorf("task with id %d not found", id)
}
delete(r.tasks, id)
return nil
}El sync.RWMutex es necesario porque un servidor HTTP maneja peticiones concurrentes. Sin él, dos peticiones simultáneas escribiendo en el mapa causan un data race que Go detecta en runtime con un panic. Esto es algo que no ves en desarrollo con un solo cliente, pero que explota en producción.
RLock para lecturas (permite múltiples lectores concurrentes) y Lock para escrituras (acceso exclusivo). Es la diferencia entre un servidor que escala y uno que serializa todo.
Gestión de errores: no dejes que HTTP decida tu lógica
Este es el punto donde la mayoría de tutoriales fallan. Devuelven http.StatusInternalServerError para todo, o peor, dejan que el handler decida qué código HTTP corresponde a cada error del repositorio. Eso acopla las capas y te obliga a importar net/http en sitios donde no debería estar.
La solución es definir tus propios tipos de error en el dominio:
// internal/apperror/errors.go
package apperror
import "fmt"
type ErrorType int
const (
NotFound ErrorType = iota
Validation
Conflict
Internal
)
type AppError struct {
Type ErrorType
Message string
Err error
}
func (e *AppError) Error() string {
if e.Err != nil {
return fmt.Sprintf("%s: %v", e.Message, e.Err)
}
return e.Message
}
func (e *AppError) Unwrap() error {
return e.Err
}
func NewNotFound(msg string) *AppError {
return &AppError{Type: NotFound, Message: msg}
}
func NewValidation(msg string) *AppError {
return &AppError{Type: Validation, Message: msg}
}
func NewInternal(msg string, err error) *AppError {
return &AppError{Type: Internal, Message: msg, Err: err}
}Ahora el servicio puede devolver apperror.NewNotFound("task not found") sin saber nada de HTTP, y el handler puede mapear NotFound a 404, Validation a 400, etc. Cada capa habla su idioma.
Si quieres profundizar en patrones de errores en Go, te recomiendo dedicarle tiempo porque es una de las partes del lenguaje que más afecta a la calidad del código a largo plazo.
El servicio: lógica de negocio separada de HTTP
El servicio es donde vive la lógica que no es ni HTTP ni acceso a datos. Validaciones de negocio, transformaciones, reglas que aplican independientemente de si la petición viene de una API REST, un CLI o un test.
// internal/service/task.go
package service
import (
"errors"
"strings"
"github.com/tu-usuario/todo-api/internal/apperror"
"github.com/tu-usuario/todo-api/internal/model"
"github.com/tu-usuario/todo-api/internal/repository"
)
type TaskService struct {
repo repository.TaskRepository
}
func NewTaskService(repo repository.TaskRepository) *TaskService {
return &TaskService{repo: repo}
}
func (s *TaskService) GetAll() ([]model.Task, error) {
return s.repo.GetAll()
}
func (s *TaskService) GetByID(id int) (*model.Task, error) {
task, err := s.repo.GetByID(id)
if err != nil {
return nil, apperror.NewNotFound("task not found")
}
return task, nil
}
func (s *TaskService) Create(req model.CreateTaskRequest) (*model.Task, error) {
if strings.TrimSpace(req.Title) == "" {
return nil, apperror.NewValidation("title is required")
}
task := &model.Task{
Title: strings.TrimSpace(req.Title),
Description: strings.TrimSpace(req.Description),
Done: false,
}
if err := s.repo.Create(task); err != nil {
return nil, apperror.NewInternal("failed to create task", err)
}
return task, nil
}
func (s *TaskService) Update(id int, req model.UpdateTaskRequest) (*model.Task, error) {
task, err := s.repo.GetByID(id)
if err != nil {
return nil, apperror.NewNotFound("task not found")
}
if req.Title != nil {
title := strings.TrimSpace(*req.Title)
if title == "" {
return nil, apperror.NewValidation("title cannot be empty")
}
task.Title = title
}
if req.Description != nil {
task.Description = strings.TrimSpace(*req.Description)
}
if req.Done != nil {
task.Done = *req.Done
}
if err := s.repo.Update(task); err != nil {
return nil, apperror.NewInternal("failed to update task", err)
}
return task, nil
}
func (s *TaskService) Delete(id int) error {
_, err := s.repo.GetByID(id)
if err != nil {
return apperror.NewNotFound("task not found")
}
if err := s.repo.Delete(id); err != nil {
return apperror.NewInternal("failed to delete task", err)
}
return nil
}Observa varias cosas:
- El servicio recibe la interfaz
TaskRepository, no la implementación concreta. Esto significa que en los tests puedes pasar un mock sin tocar nada. - Las validaciones están en el servicio, no en el handler. El handler solo parsea el JSON y llama al servicio. Si mañana añades un CLI que crea tareas, las validaciones siguen aplicando.
- El
Updateusa los punteros delUpdateTaskRequest. Solo modifica los campos que el usuario envió. Un PATCH real, no un PUT disfrazado. - Los errores del repositorio se transforman en
AppError. El handler nunca ve unfmt.Errorfdel repo directamente.
Fíjate también en que GetAll no transforma el error. Si el repositorio falla, el error sube tal cual. No siempre necesitas envolver cada error: a veces la transparencia es mejor que la ceremonia.
Los handlers: la capa HTTP
El handler es la frontera entre HTTP y tu dominio. Su trabajo es simple: leer la petición, llamar al servicio, escribir la respuesta. Nada de lógica de negocio aquí.
// internal/handler/task.go
package handler
import (
"encoding/json"
"errors"
"net/http"
"strconv"
"github.com/tu-usuario/todo-api/internal/apperror"
"github.com/tu-usuario/todo-api/internal/model"
"github.com/tu-usuario/todo-api/internal/service"
)
type TaskHandler struct {
service *service.TaskService
}
func NewTaskHandler(service *service.TaskService) *TaskHandler {
return &TaskHandler{service: service}
}
type ErrorResponse struct {
Error string `json:"error"`
}
func (h *TaskHandler) writeJSON(w http.ResponseWriter, status int, data any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(data)
}
func (h *TaskHandler) writeError(w http.ResponseWriter, err error) {
var appErr *apperror.AppError
if errors.As(err, &appErr) {
status := http.StatusInternalServerError
switch appErr.Type {
case apperror.NotFound:
status = http.StatusNotFound
case apperror.Validation:
status = http.StatusBadRequest
case apperror.Conflict:
status = http.StatusConflict
}
h.writeJSON(w, status, ErrorResponse{Error: appErr.Message})
return
}
h.writeJSON(w, http.StatusInternalServerError, ErrorResponse{Error: "internal server error"})
}El método writeError es el que hace la traducción entre los errores del dominio y los códigos HTTP. Un switch limpio, sin ifs anidados, fácil de extender. Cuando añadas un nuevo tipo de error (por ejemplo, Unauthorized), solo añades un case aquí.
Ahora los handlers concretos:
func (h *TaskHandler) GetAll(w http.ResponseWriter, r *http.Request) {
tasks, err := h.service.GetAll()
if err != nil {
h.writeError(w, err)
return
}
h.writeJSON(w, http.StatusOK, tasks)
}
func (h *TaskHandler) GetByID(w http.ResponseWriter, r *http.Request) {
id, err := strconv.Atoi(r.PathValue("id"))
if err != nil {
h.writeJSON(w, http.StatusBadRequest, ErrorResponse{Error: "invalid id"})
return
}
task, err := h.service.GetByID(id)
if err != nil {
h.writeError(w, err)
return
}
h.writeJSON(w, http.StatusOK, task)
}
func (h *TaskHandler) Create(w http.ResponseWriter, r *http.Request) {
var req model.CreateTaskRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
h.writeJSON(w, http.StatusBadRequest, ErrorResponse{Error: "invalid request body"})
return
}
task, err := h.service.Create(req)
if err != nil {
h.writeError(w, err)
return
}
h.writeJSON(w, http.StatusCreated, task)
}
func (h *TaskHandler) Update(w http.ResponseWriter, r *http.Request) {
id, err := strconv.Atoi(r.PathValue("id"))
if err != nil {
h.writeJSON(w, http.StatusBadRequest, ErrorResponse{Error: "invalid id"})
return
}
var req model.UpdateTaskRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
h.writeJSON(w, http.StatusBadRequest, ErrorResponse{Error: "invalid request body"})
return
}
task, err := h.service.Update(id, req)
if err != nil {
h.writeError(w, err)
return
}
h.writeJSON(w, http.StatusOK, task)
}
func (h *TaskHandler) Delete(w http.ResponseWriter, r *http.Request) {
id, err := strconv.Atoi(r.PathValue("id"))
if err != nil {
h.writeJSON(w, http.StatusBadRequest, ErrorResponse{Error: "invalid id"})
return
}
if err := h.service.Delete(id); err != nil {
h.writeError(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}Cada handler sigue el mismo patrón: parsear input, llamar al servicio, devolver resultado o error. No hay lógica de negocio. No hay acceso a datos. Si lees un handler y necesitas más de 10 segundos para entender qué hace, algo está mal.
Un detalle importante: r.PathValue("id") es de Go 1.22+, que añadió soporte nativo para parámetros en rutas. Antes necesitabas un router externo como Gorilla Mux o Chi para algo tan básico. Si estás en una versión anterior, tendrás que usar un router de terceros o parsear la URL a mano.
Routing: conectando rutas y handlers
Con Go 1.22, la librería estándar net/http soporta métodos HTTP y parámetros de ruta. Esto significa que para una API como la nuestra, no necesitas Gin, Chi ni ningún framework externo.
func setupRoutes(taskHandler *handler.TaskHandler) *http.ServeMux {
mux := http.NewServeMux()
mux.HandleFunc("GET /api/tasks", taskHandler.GetAll)
mux.HandleFunc("GET /api/tasks/{id}", taskHandler.GetByID)
mux.HandleFunc("POST /api/tasks", taskHandler.Create)
mux.HandleFunc("PUT /api/tasks/{id}", taskHandler.Update)
mux.HandleFunc("DELETE /api/tasks/{id}", taskHandler.Delete)
return mux
}Limpio, sin magia, sin dependencias. El patrón "GET /api/tasks/{id}" le dice al mux que solo haga match con peticiones GET que tengan ese path, y que extraiga {id} como parámetro accesible con r.PathValue("id").
Y si prefieres Gin?
Si tu API va a crecer y necesitas middleware más sofisticado, validación de bindings, o simplemente prefieres la ergonomía de Gin, la adaptación es directa:
func setupGinRoutes(taskHandler *handler.TaskHandler) *gin.Engine {
r := gin.Default()
api := r.Group("/api")
{
api.GET("/tasks", wrapHandler(taskHandler.GetAll))
api.GET("/tasks/:id", wrapHandler(taskHandler.GetByID))
api.POST("/tasks", wrapHandler(taskHandler.Create))
api.PUT("/tasks/:id", wrapHandler(taskHandler.Update))
api.DELETE("/tasks/:id", wrapHandler(taskHandler.Delete))
}
return r
}
func wrapHandler(h http.HandlerFunc) gin.HandlerFunc {
return func(c *gin.Context) {
h(c.Writer, c.Request)
}
}Pero mi recomendación para una API nueva en Go 1.22+: empieza con la librería estándar. Añades dependencias cuando las necesitas, no antes. Go tiene esa virtud: la stdlib es suficiente para el 80% de los casos.
Configuración: variables de entorno
La configuración hardcodeada es el origen de la mitad de los bugs de despliegue. Puerto del servidor, URLs de base de datos, timeouts: todo eso tiene que venir del entorno.
// internal/config/config.go
package config
import (
"fmt"
"os"
"strconv"
"time"
)
type Config struct {
Port int
ReadTimeout time.Duration
WriteTimeout time.Duration
ShutdownTimeout time.Duration
}
func Load() (*Config, error) {
port, err := getEnvInt("PORT", 8080)
if err != nil {
return nil, fmt.Errorf("invalid PORT: %w", err)
}
readTimeout, err := getEnvDuration("READ_TIMEOUT", 5*time.Second)
if err != nil {
return nil, fmt.Errorf("invalid READ_TIMEOUT: %w", err)
}
writeTimeout, err := getEnvDuration("WRITE_TIMEOUT", 10*time.Second)
if err != nil {
return nil, fmt.Errorf("invalid WRITE_TIMEOUT: %w", err)
}
shutdownTimeout, err := getEnvDuration("SHUTDOWN_TIMEOUT", 15*time.Second)
if err != nil {
return nil, fmt.Errorf("invalid SHUTDOWN_TIMEOUT: %w", err)
}
return &Config{
Port: port,
ReadTimeout: readTimeout,
WriteTimeout: writeTimeout,
ShutdownTimeout: shutdownTimeout,
}, nil
}
func getEnvInt(key string, defaultVal int) (int, error) {
val, exists := os.LookupEnv(key)
if !exists {
return defaultVal, nil
}
return strconv.Atoi(val)
}
func getEnvDuration(key string, defaultVal time.Duration) (time.Duration, error) {
val, exists := os.LookupEnv(key)
if !exists {
return defaultVal, nil
}
return time.ParseDuration(val)
}Cada valor tiene un default sensato, y si alguien pasa un valor inválido, el servidor falla al arrancar con un mensaje claro en lugar de comportarse de forma impredecible en runtime.
No uso librerías como Viper ni godotenv aquí. Para una API con cuatro variables, os.LookupEnv y funciones helper son más que suficientes. Cuando tengas 20 variables y necesites validación compleja, entonces evalúa si una librería aporta algo.
El main.go: conectando todo
El main.go es el único sitio donde todas las capas se conocen entre sí. Aquí es donde haces el wiring manual de dependencias. No hay inyección de dependencias automática como en Spring, y eso es una ventaja: puedes leer el main y saber exactamente cómo se monta el servidor.
// cmd/server/main.go
package main
import (
"context"
"fmt"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"github.com/tu-usuario/todo-api/internal/config"
"github.com/tu-usuario/todo-api/internal/handler"
"github.com/tu-usuario/todo-api/internal/repository"
"github.com/tu-usuario/todo-api/internal/service"
)
func main() {
cfg, err := config.Load()
if err != nil {
log.Fatalf("failed to load config: %v", err)
}
// Wiring de dependencias
taskRepo := repository.NewInMemoryTaskRepository()
taskService := service.NewTaskService(taskRepo)
taskHandler := handler.NewTaskHandler(taskService)
// Rutas
mux := http.NewServeMux()
mux.HandleFunc("GET /api/tasks", taskHandler.GetAll)
mux.HandleFunc("GET /api/tasks/{id}", taskHandler.GetByID)
mux.HandleFunc("POST /api/tasks", taskHandler.Create)
mux.HandleFunc("PUT /api/tasks/{id}", taskHandler.Update)
mux.HandleFunc("DELETE /api/tasks/{id}", taskHandler.Delete)
// Servidor con timeouts
server := &http.Server{
Addr: fmt.Sprintf(":%d", cfg.Port),
Handler: mux,
ReadTimeout: cfg.ReadTimeout,
WriteTimeout: cfg.WriteTimeout,
}
// Graceful shutdown
go func() {
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
<-sigChan
log.Println("shutting down server...")
ctx, cancel := context.WithTimeout(context.Background(), cfg.ShutdownTimeout)
defer cancel()
if err := server.Shutdown(ctx); err != nil {
log.Printf("server shutdown error: %v", err)
}
}()
log.Printf("server starting on port %d", cfg.Port)
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("server error: %v", err)
}
log.Println("server stopped")
}Tres cosas que muchos tutoriales omiten y que aquí están desde el principio:
Timeouts en el servidor. Un http.Server sin ReadTimeout ni WriteTimeout acepta conexiones que pueden quedarse abiertas indefinidamente. En producción, eso es un vector de ataque trivial (slowloris) y un leak de recursos.
Graceful shutdown. Cuando el proceso recibe un SIGINT o SIGTERM (que es lo que manda Kubernetes, Docker, o un Ctrl+C), no corta las conexiones abiertas de golpe. Le da un tiempo al servidor para terminar las peticiones en curso. Sin esto, tus usuarios ven errores 502 cada vez que despliegas.
Wiring explícito. Repo -> Service -> Handler. Puedes leer las tres líneas y entender la cadena de dependencias completa. No hay contenedores de IoC, no hay reflexión, no hay autoconfiguración que adivine qué inyectar. En Go, esto es una feature.
Probando la API
Arrancamos el servidor:
go run ./cmd/server/Y probamos con curl:
# Crear una tarea
curl -s -X POST http://localhost:8080/api/tasks \
-H "Content-Type: application/json" \
-d '{"title": "Aprender Go", "description": "Construir una API REST"}' | jq
# Respuesta:
# {
# "id": 1,
# "title": "Aprender Go",
# "description": "Construir una API REST",
# "done": false,
# "created_at": "2026-07-02T10:30:00Z",
# "updated_at": "2026-07-02T10:30:00Z"
# }
# Listar todas las tareas
curl -s http://localhost:8080/api/tasks | jq
# Obtener una tarea por ID
curl -s http://localhost:8080/api/tasks/1 | jq
# Actualizar parcialmente (solo marcar como completada)
curl -s -X PUT http://localhost:8080/api/tasks/1 \
-H "Content-Type: application/json" \
-d '{"done": true}' | jq
# Eliminar una tarea
curl -s -X DELETE http://localhost:8080/api/tasks/1 -w "\n%{http_code}\n"
# 204
# Intentar obtener una tarea que no existe
curl -s http://localhost:8080/api/tasks/999 | jq
# {
# "error": "task not found"
# }La API responde JSON consistente tanto en éxito como en error. No hay HTMLs de error por defecto, no hay mensajes de Go crudos. Cada respuesta tiene un formato predecible que un frontend o un cliente puede parsear sin condiciones especiales.
Lo que no está y por qué
Este artículo cubre la estructura, no todas las features de producción. Hay cosas que deliberadamente no están pero que necesitas antes de desplegar:
Middleware de logging. Cada petición debería loguear método, path, status code y duración. Con net/http puedes escribir un middleware que envuelva el handler en 15 líneas.
Base de datos real. El repositorio en memoria es para demostrar el patrón. Para producción necesitas PostgreSQL u otra base de datos detrás de la misma interfaz TaskRepository.
Validación más robusta. El servicio valida que el título no esté vacío, pero en un proyecto real necesitas validación de longitudes, formatos, y probablemente una librería como go-playground/validator.
Tests. Gracias a las interfaces, testear cada capa es directo: mock del repositorio para testear el servicio, mock del servicio para testear el handler. Merece un artículo propio sobre testing en Go.
Docker. Para desplegar esto necesitas un Dockerfile multi-stage que compile el binario y lo ejecute en una imagen mínima. Cubro eso en dockerizar una API Go.
Autenticación y autorización. Middleware de JWT, API keys, o lo que necesite tu caso. No está porque es ortogonal a la estructura, pero es imprescindible antes de exponer la API.
Visión de conjunto
Hagamos zoom out. El flujo completo de una petición HTTP a través de esta arquitectura es:
Petición HTTP
│
▼
Handler → Parsea request, llama al servicio, escribe response
│
▼
Service → Valida, aplica lógica de negocio, llama al repositorio
│
▼
Repository → Lee/escribe datos (memoria, PostgreSQL, lo que sea)
│
▼
Modelo → Tipos de dominio que atraviesan todas las capas
│
▼
AppError → Errores tipados que el handler traduce a HTTPCada capa tiene una única responsabilidad. Cada capa se comunica con la siguiente a través de interfaces o tipos compartidos. Ninguna capa sabe cómo funcionan las demás por dentro.
Esto no es arquitectura hexagonal ni clean architecture con puertos y adaptadores y 47 interfaces. Es separación de responsabilidades básica con las herramientas que Go te da. Y para el 90% de las APIs que vas a construir, es suficiente.
La clave no es la estructura en sí, es la disciplina de mantenerla. Cuando tengas prisa y quieras meter una query SQL dentro de un handler “solo esta vez”, recuerda que esa excepción se convierte en la norma en dos sprints.
Qué sigue
Desde esta base, los siguientes pasos naturales son:
- Añadir persistencia real con PostgreSQL: implementar
TaskRepositorycondatabase/sqlosqlx. - Escribir tests para cada capa aprovechando las interfaces que ya tienes. Lo detallo en testing en Go.
- Containerizar la aplicación con un Dockerfile multi-stage para tener un binario de 15MB en una imagen scratch. Ver dockerizar API Go.
- Añadir middleware de logging, recovery y CORS.
- Documentar la API con OpenAPI/Swagger si la van a consumir otros equipos.
La estructura que hemos montado soporta todos estos cambios sin reescribir lo que ya funciona. Ese era el objetivo desde el principio: no hacer el TODO más bonito del mundo, sino hacer uno que puedas seguir manteniendo.


