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.

Cover for Crear una API REST con Go desde cero: estructura limpia y código mantenible

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

Hay decisiones deliberadas aquí:

  • cmd/server/: El punto de entrada. Si mañana necesitas un CLI o un worker, añades cmd/cli/ y cmd/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. No task/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-api

Los 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í:

  • Task es el modelo de dominio. Tiene todos los campos, incluidos los que genera el sistema (ID, CreatedAt, UpdatedAt).
  • CreateTaskRequest no tiene ID ni timestamps. Esos campos no los pone el usuario. Separar el modelo de creación del modelo de dominio te evita bugs donde alguien manda un id en el body y espera que se respete.
  • UpdateTaskRequest usa punteros. Un *string puede ser nil, 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 Update usa los punteros del UpdateTaskRequest. 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 un fmt.Errorf del 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 HTTP

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

  1. Añadir persistencia real con PostgreSQL: implementar TaskRepository con database/sql o sqlx.
  2. Escribir tests para cada capa aprovechando las interfaces que ya tienes. Lo detallo en testing en Go.
  3. Containerizar la aplicación con un Dockerfile multi-stage para tener un binario de 15MB en una imagen scratch. Ver dockerizar API Go.
  4. Añadir middleware de logging, recovery y CORS.
  5. 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.

OshyTech

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

Navegación

Copyright 2026 OshyTech. Todos los derechos reservados