REST API Nativa en Go 1.25: Clean Architecture, Middleware y Escalabilidad sin Frameworks

REST API Nativa en Go 1.25: Clean Architecture, Middleware y Escalabilidad sin Frameworks

Una guía exhaustiva sobre cómo construir APIs REST profesionales con Go 1.25 usando solo la biblioteca estándar: rutas, middleware, CORS, validación, manejo de JSON/XML, clean architecture y ejemplos paso a paso para aplicaciones empresariales.

Por Omar Flores

Existe una creencia común entre desarrolladores que llegan a Go desde otros lenguajes: “Para construir una API REST profesional, necesito un framework como Gin o Echo”. No es verdad.

Go fue diseñado desde el inicio con APIs web en mente. Su biblioteca estándar, particularmente el paquete net/http, es tan potente y flexible que muchos de los “frameworks populares” que ves en Go son poco más que capas de conveniencia sobre lo que ya existe en la stdlib. Y aunque esas capas pueden ser útiles, entender cómo construir una API REST directamente con la biblioteca estándar es una de las skills más valiosas que puedes desarrollar como desarrollador de Go.

He visto equipos que dependen completamente de frameworks para cada pequeña tarea, y luego quedan paralizados cuando necesitan algo que el framework no hace. He visto otros equipos que entienden la stdlib a fondo, y pueden resolver problemas complejos con elegancia, sin dependencias externas innecesarias.

Este artículo es una guía exhaustiva, profesional y paso a paso sobre cómo construir APIs REST escalables con Go puro: manejo de rutas, middleware robusto, CORS, validación, serialización JSON/XML, manejo de errores, y todo estructurado bajo principios de Clean Architecture. No es teoría abstracta. Es código real, ejemplos ejecutables, y decisiones de arquitectura explicadas.

Al final de este artículo, comprenderás no solo cómo hacer funcionar una API REST, sino cómo diseñarla de manera que escale con tu negocio, sea fácil de mantener, y que otros desarrolladores puedan entender sin fricción.


La Potencia Oculta de net/http

Antes de escribir una línea de código, necesitas entender qué tienes disponible en la biblioteca estándar. Porque si entiendes bien net/http, vas a escribir mejor código, tomes o no un framework.

El Núcleo: http.Handler

Todo en Go HTTP se construye alrededor de una interfaz simple:

type Handler interface {
    ServeHTTP(ResponseWriter, *Request)
}

Eso es. Si tu tipo implementa un método ServeHTTP que recibe un ResponseWriter y un puntero a Request, es un handler. Punto.

¿Por qué esto importa? Porque esta simplicidad es poder. No estás atrapado en un patrón de decoradores, middleware chains, o plugins. Solo interfaces Go puras.

El Enrutador: http.ServeMux

Go incluye un enrutador básico llamado ServeMux:

mux := http.NewServeMux()
mux.HandleFunc("/usuarios", handleGetUsuarios)
mux.HandleFunc("/usuarios/{id}", handleGetUsuario)

http.ListenAndServe(":8080", mux)

Espera, ¿acabo de ver parámetros en las rutas con llaves? Sí. Go 1.22 agregó pattern matching mejorado a http.ServeMux. No es tan avanzado como algunos frameworks, pero es suficiente para la mayoría de APIs.

Importante: Si necesitas enrutamiento más avanzado (capture groups complejos, priorización, etc), necesitarás un router custom. Pero para la mayoría de APIs REST bien diseñadas, el patrón simple es suficiente.


Proyecto Paso a Paso: API de Gestión de Proyectos

En lugar de fragmentos aislados, vamos a construir una API completa, real, que automatiza la gestión de proyectos. Será extenso. Será profesional. Y verás cómo todo encaja.

Fase 1: Estructura de Proyecto y Setup Inicial

Paso 1: Crear la estructura base

mkdir proyecto-api-go
cd proyecto-api-go

go mod init github.com/tuusuario/proyecto-api

# Crear carpetas según Clean Architecture
mkdir -p internal/{domain,usecase,adapter/http,adapter/storage}
mkdir -p cmd/api
mkdir config logs tmp

Tu estructura quedará así:

proyecto-api-go/
├── cmd/
│   └── api/
│       └── main.go           # Punto de entrada
├── internal/
│   ├── domain/               # Entidades del negocio
│   │   ├── project.go
│   │   └── errors.go
│   ├── usecase/              # Lógica de negocio
│   │   ├── create_project.go
│   │   ├── list_projects.go
│   │   └── get_project.go
│   ├── adapter/
│   │   ├── http/             # Handlers HTTP
│   │   │   ├── handler.go
│   │   │   ├── middleware.go
│   │   │   └── response.go
│   │   └── storage/          # Datos (en memoria por ahora)
│   │       └── project_storage.go
├── config/                   # Archivos de configuración
├── logs/                     # Logs de la aplicación
├── go.mod
└── README.md

Esta estructura respeta Clean Architecture: dependencias apuntan hacia adentro, el negocio está aislado de frameworks, y puedes cambiar adaptadores sin tocar lógica de negocio.

Fase 2: Definición del Dominio

Paso 2: Crear las entidades del negocio

En internal/domain/project.go:

package domain

import (
	"time"
)

// Project representa un proyecto en nuestro negocio
// Esta es la verdad del dominio, independiente de HTTP, almacenamiento, etc
type Project struct {
	ID          string    `json:"id"`
	Title       string    `json:"title"`
	Description string    `json:"description"`
	Status      string    `json:"status"` // "pendiente", "en-progreso", "completado"
	CreatedAt   time.Time `json:"created_at"`
	UpdatedAt   time.Time `json:"updated_at"`
}

// CreateProjectInput es lo que recibimos del cliente
// Nota: NO incluye ID ni timestamps, esos se generan internamente
type CreateProjectInput struct {
	Title       string `json:"title"`
	Description string `json:"description"`
}

// UpdateProjectInput es para actualizar
type UpdateProjectInput struct {
	Title       *string `json:"title,omitempty"`
	Description *string `json:"description,omitempty"`
	Status      *string `json:"status,omitempty"`
}

¿Por qué separamos Project de CreateProjectInput?

  1. Seguridad: El cliente no puede manipular ID o timestamps
  2. Flexibilidad: Puedes cambiar qué requiere creación sin afectar la entidad
  3. Claridad: Es explícito qué datos se esperan en cada operación

En internal/domain/errors.go:

package domain

import "fmt"

// Error personalizado del dominio
type DomainError struct {
	Code    string
	Message string
	Details map[string]interface{}
}

func (e *DomainError) Error() string {
	return fmt.Sprintf("[%s] %s", e.Code, e.Message)
}

// Errores específicos del dominio
func NewProjectNotFoundError(id string) *DomainError {
	return &DomainError{
		Code:    "PROJECT_NOT_FOUND",
		Message: "El proyecto no existe",
		Details: map[string]interface{}{"id": id},
	}
}

func NewInvalidProjectError(reason string) *DomainError {
	return &DomainError{
		Code:    "INVALID_PROJECT",
		Message: "Datos del proyecto inválidos: " + reason,
		Details: make(map[string]interface{}),
	}
}

Fase 3: Almacenamiento (Adapter)

Paso 3: Crear un repositorio en memoria

En internal/adapter/storage/project_storage.go:

package storage

import (
	"sync"
	"time"

	"github.com/tuusuario/proyecto-api/internal/domain"
	"github.com/google/uuid"
)

// ProjectStorage define la interfaz que necesita el use case
// Nota: Esto es un puerto (en Clean Architecture)
type ProjectStorage interface {
	Create(p *domain.Project) error
	GetByID(id string) (*domain.Project, error)
	List() ([]*domain.Project, error)
	Update(id string, p *domain.Project) error
	Delete(id string) error
}

// InMemoryProjectStorage es un adapter que almacena en memoria
type InMemoryProjectStorage struct {
	mu       sync.RWMutex
	projects map[string]*domain.Project
}

// NewInMemoryProjectStorage crea una nueva instancia
func NewInMemoryProjectStorage() *InMemoryProjectStorage {
	return &InMemoryProjectStorage{
		projects: make(map[string]*domain.Project),
	}
}

func (s *InMemoryProjectStorage) Create(p *domain.Project) error {
	s.mu.Lock()
	defer s.mu.Unlock()

	// Genera ID si no existe
	if p.ID == "" {
		p.ID = uuid.New().String()
	}

	// Timestamps
	now := time.Now()
	p.CreatedAt = now
	p.UpdatedAt = now

	// Default status
	if p.Status == "" {
		p.Status = "pendiente"
	}

	s.projects[p.ID] = p
	return nil
}

func (s *InMemoryProjectStorage) GetByID(id string) (*domain.Project, error) {
	s.mu.RLock()
	defer s.mu.RUnlock()

	p, exists := s.projects[id]
	if !exists {
		return nil, domain.NewProjectNotFoundError(id)
	}

	return p, nil
}

func (s *InMemoryProjectStorage) List() ([]*domain.Project, error) {
	s.mu.RLock()
	defer s.mu.RUnlock()

	projects := make([]*domain.Project, 0, len(s.projects))
	for _, p := range s.projects {
		projects = append(projects, p)
	}

	return projects, nil
}

func (s *InMemoryProjectStorage) Update(id string, updated *domain.Project) error {
	s.mu.Lock()
	defer s.mu.Unlock()

	p, exists := s.projects[id]
	if !exists {
		return domain.NewProjectNotFoundError(id)
	}

	// Solo actualiza los campos que cambien
	if updated.Title != "" {
		p.Title = updated.Title
	}
	if updated.Description != "" {
		p.Description = updated.Description
	}
	if updated.Status != "" {
		p.Status = updated.Status
	}

	p.UpdatedAt = time.Now()

	return nil
}

func (s *InMemoryProjectStorage) Delete(id string) error {
	s.mu.Lock()
	defer s.mu.Unlock()

	if _, exists := s.projects[id]; !exists {
		return domain.NewProjectNotFoundError(id)
	}

	delete(s.projects, id)
	return nil
}

¿Qué ves aquí?

  • Thread-safe: Usamos sync.RWMutex para que múltiples goroutines puedan acceder simultáneamente
  • Interfaz clara: Solo definen contrato con ProjectStorage
  • Generación de IDs: Usa UUID, que es determinístico y único
  • Timestamps: Se generan automáticamente, el cliente no puede manipularlos
  • Errores del dominio: Usa los errores que definimos en domain/

Fase 4: Use Cases (Lógica de Negocio)

Paso 4: Implementar casos de uso

En internal/usecase/create_project.go:

package usecase

import (
	"github.com/tuusuario/proyecto-api/internal/adapter/storage"
	"github.com/tuusuario/proyecto-api/internal/domain"
)

// CreateProjectUseCase encapsula la lógica de crear un proyecto
type CreateProjectUseCase struct {
	storage storage.ProjectStorage
}

// NewCreateProjectUseCase crea una nueva instancia
func NewCreateProjectUseCase(storage storage.ProjectStorage) *CreateProjectUseCase {
	return &CreateProjectUseCase{storage: storage}
}

// Execute ejecuta el caso de uso
// Recibe datos del cliente, valida, y crea el proyecto
func (uc *CreateProjectUseCase) Execute(input *domain.CreateProjectInput) (*domain.Project, error) {
	// Validación en el dominio
	if input.Title == "" {
		return nil, domain.NewInvalidProjectError("title es requerido")
	}

	if len(input.Title) > 255 {
		return nil, domain.NewInvalidProjectError("title no puede exceder 255 caracteres")
	}

	// Crear entidad de dominio
	project := &domain.Project{
		Title:       input.Title,
		Description: input.Description,
	}

	// Persistir
	if err := uc.storage.Create(project); err != nil {
		return nil, err
	}

	return project, nil
}

En internal/usecase/list_projects.go:

package usecase

import (
	"github.com/tuusuario/proyecto-api/internal/adapter/storage"
	"github.com/tuusuario/proyecto-api/internal/domain"
)

type ListProjectsUseCase struct {
	storage storage.ProjectStorage
}

func NewListProjectsUseCase(storage storage.ProjectStorage) *ListProjectsUseCase {
	return &ListProjectsUseCase{storage: storage}
}

func (uc *ListProjectsUseCase) Execute() ([]*domain.Project, error) {
	return uc.storage.List()
}

En internal/usecase/get_project.go:

package usecase

import (
	"github.com/tuusuario/proyecto-api/internal/adapter/storage"
	"github.com/tuusuario/proyecto-api/internal/domain"
)

type GetProjectUseCase struct {
	storage storage.ProjectStorage
}

func NewGetProjectUseCase(storage storage.ProjectStorage) *GetProjectUseCase {
	return &GetProjectUseCase{storage: storage}
}

func (uc *GetProjectUseCase) Execute(id string) (*domain.Project, error) {
	if id == "" {
		return nil, domain.NewInvalidProjectError("id es requerido")
	}

	return uc.storage.GetByID(id)
}

En internal/usecase/update_project.go:

package usecase

import (
	"github.com/tuusuario/proyecto-api/internal/adapter/storage"
	"github.com/tuusuario/proyecto-api/internal/domain"
)

type UpdateProjectUseCase struct {
	storage storage.ProjectStorage
}

func NewUpdateProjectUseCase(storage storage.ProjectStorage) *UpdateProjectUseCase {
	return &UpdateProjectUseCase{storage: storage}
}

func (uc *UpdateProjectUseCase) Execute(id string, input *domain.UpdateProjectInput) (*domain.Project, error) {
	if id == "" {
		return nil, domain.NewInvalidProjectError("id es requerido")
	}

	// Obtener proyecto actual
	project, err := uc.storage.GetByID(id)
	if err != nil {
		return nil, err
	}

	// Actualizar solo campos no-nil
	if input.Title != nil {
		if *input.Title == "" {
			return nil, domain.NewInvalidProjectError("title no puede estar vacío")
		}
		project.Title = *input.Title
	}

	if input.Description != nil {
		project.Description = *input.Description
	}

	if input.Status != nil {
		// Validar que el status sea válido
		validStatuses := map[string]bool{
			"pendiente":    true,
			"en-progreso":  true,
			"completado":   true,
		}

		if !validStatuses[*input.Status] {
			return nil, domain.NewInvalidProjectError("status inválido")
		}

		project.Status = *input.Status
	}

	// Persistir cambios
	if err := uc.storage.Update(id, project); err != nil {
		return nil, err
	}

	return project, nil
}

En internal/usecase/delete_project.go:

package usecase

import (
	"github.com/tuusuario/proyecto-api/internal/adapter/storage"
	"github.com/tuusuario/proyecto-api/internal/domain"
)

type DeleteProjectUseCase struct {
	storage storage.ProjectStorage
}

func NewDeleteProjectUseCase(storage storage.ProjectStorage) *DeleteProjectUseCase {
	return &DeleteProjectUseCase{storage: storage}
}

func (uc *DeleteProjectUseCase) Execute(id string) error {
	if id == "" {
		return domain.NewInvalidProjectError("id es requerido")
	}

	return uc.storage.Delete(id)
}

¿Ves el patrón?

  • Cada use case es una responsabilidad única
  • Reciben interfaces, no implementaciones concretas
  • Manejan validación del dominio
  • Son independientes de cómo los datos se persisten o se presentan
  • Puedes testar sin HTTP, sin base de datos

Fase 5: HTTP Handlers y Routing

Paso 5: Crear los handlers HTTP

En internal/adapter/http/response.go:

package http

import (
	"encoding/json"
	"encoding/xml"
	"net/http"

	"github.com/tuusuario/proyecto-api/internal/domain"
)

// SuccessResponse es la estructura estándar para respuestas exitosas
type SuccessResponse struct {
	Status  string      `json:"status"`
	Code    int         `json:"code"`
	Data    interface{} `json:"data"`
	Message string      `json:"message,omitempty"`
}

// ErrorResponse es la estructura estándar para respuestas de error
type ErrorResponse struct {
	Status  string                 `json:"status"`
	Code    int                    `json:"code"`
	Error   string                 `json:"error"`
	Message string                 `json:"message"`
	Details map[string]interface{} `json:"details,omitempty"`
}

// WriteJSON escribe una respuesta exitosa en JSON
func WriteJSON(w http.ResponseWriter, statusCode int, data interface{}) error {
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(statusCode)

	return json.NewEncoder(w).Encode(SuccessResponse{
		Status: "success",
		Code:   statusCode,
		Data:   data,
	})
}

// WriteXML escribe una respuesta exitosa en XML
func WriteXML(w http.ResponseWriter, statusCode int, data interface{}) error {
	w.Header().Set("Content-Type", "application/xml")
	w.WriteHeader(statusCode)

	// Envelope XML para que sea válido
	return xml.NewEncoder(w).Encode(struct {
		XMLName xml.Name    `xml:"response"`
		Status  string      `xml:"status"`
		Code    int         `xml:"code"`
		Data    interface{} `xml:"data"`
	}{
		Status: "success",
		Code:   statusCode,
		Data:   data,
	})
}

// WriteError escribe una respuesta de error
func WriteError(w http.ResponseWriter, statusCode int, err error) error {
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(statusCode)

	errResp := ErrorResponse{
		Status:  "error",
		Code:    statusCode,
		Message: err.Error(),
	}

	// Si es un DomainError, incluye detalles
	if domainErr, ok := err.(*domain.DomainError); ok {
		errResp.Error = domainErr.Code
		errResp.Details = domainErr.Details
	}

	return json.NewEncoder(w).Encode(errResp)
}

// ContentType retorna el tipo de contenido basado en el header Accept
// Ej: "Accept: application/json" retorna "json"
//     "Accept: application/xml" retorna "xml"
func ContentType(r *http.Request) string {
	accept := r.Header.Get("Accept")

	switch accept {
	case "application/xml":
		return "xml"
	default:
		return "json"
	}
}

En internal/adapter/http/handler.go:

package http

import (
	"encoding/json"
	"fmt"
	"net/http"
	"strconv"

	"github.com/tuusuario/proyecto-api/internal/domain"
	"github.com/tuusuario/proyecto-api/internal/usecase"
)

// Handler agrupa todos los handlers HTTP
// Inyección de dependencias: recibe los use cases que necesita
type Handler struct {
	createProjectUC *usecase.CreateProjectUseCase
	listProjectsUC  *usecase.ListProjectsUseCase
	getProjectUC    *usecase.GetProjectUseCase
	updateProjectUC *usecase.UpdateProjectUseCase
	deleteProjectUC *usecase.DeleteProjectUseCase
}

// NewHandler crea una nueva instancia
func NewHandler(
	createProjectUC *usecase.CreateProjectUseCase,
	listProjectsUC *usecase.ListProjectsUseCase,
	getProjectUC *usecase.GetProjectUseCase,
	updateProjectUC *usecase.UpdateProjectUseCase,
	deleteProjectUC *usecase.DeleteProjectUseCase,
) *Handler {
	return &Handler{
		createProjectUC: createProjectUC,
		listProjectsUC:  listProjectsUC,
		getProjectUC:    getProjectUC,
		updateProjectUC: updateProjectUC,
		deleteProjectUC: deleteProjectUC,
	}
}

// CreateProject maneja POST /proyectos
// Request: JSON con title y description
// Response: Project creado con ID generado
func (h *Handler) CreateProject(w http.ResponseWriter, r *http.Request) {
	// 1. Parsear el request
	var input domain.CreateProjectInput
	if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
		WriteError(w, http.StatusBadRequest, fmt.Errorf("JSON inválido: %v", err))
		return
	}

	// 2. Ejecutar el use case
	project, err := h.createProjectUC.Execute(&input)
	if err != nil {
		// Determinar status code basado en el error
		statusCode := http.StatusInternalServerError
		if _, ok := err.(*domain.DomainError); ok {
			statusCode = http.StatusBadRequest
		}

		WriteError(w, statusCode, err)
		return
	}

	// 3. Escribir respuesta
	contentType := ContentType(r)
	if contentType == "xml" {
		WriteXML(w, http.StatusCreated, project)
	} else {
		WriteJSON(w, http.StatusCreated, project)
	}
}

// ListProjects maneja GET /proyectos
// Response: Array de todos los proyectos
func (h *Handler) ListProjects(w http.ResponseWriter, r *http.Request) {
	// Ejecutar use case
	projects, err := h.listProjectsUC.Execute()
	if err != nil {
		WriteError(w, http.StatusInternalServerError, err)
		return
	}

	// Si no hay proyectos, retorna array vacío, no error
	if projects == nil {
		projects = make([]*domain.Project, 0)
	}

	contentType := ContentType(r)
	if contentType == "xml" {
		WriteXML(w, http.StatusOK, projects)
	} else {
		WriteJSON(w, http.StatusOK, projects)
	}
}

// GetProject maneja GET /proyectos/{id}
// URL Parameter: id del proyecto
// Response: Proyecto específico
func (h *Handler) GetProject(w http.ResponseWriter, r *http.Request) {
	// Extraer el parámetro de ruta
	// Go 1.22+ permite esto de forma nativa en ServeMux
	id := r.PathValue("id")

	// Ejecutar use case
	project, err := h.getProjectUC.Execute(id)
	if err != nil {
		statusCode := http.StatusInternalServerError
		if _, ok := err.(*domain.DomainError); ok {
			if err.(*domain.DomainError).Code == "PROJECT_NOT_FOUND" {
				statusCode = http.StatusNotFound
			} else {
				statusCode = http.StatusBadRequest
			}
		}

		WriteError(w, statusCode, err)
		return
	}

	contentType := ContentType(r)
	if contentType == "xml" {
		WriteXML(w, http.StatusOK, project)
	} else {
		WriteJSON(w, http.StatusOK, project)
	}
}

// UpdateProject maneja PUT /proyectos/{id}
// URL Parameter: id del proyecto
// Request: JSON con campos a actualizar
// Response: Proyecto actualizado
func (h *Handler) UpdateProject(w http.ResponseWriter, r *http.Request) {
	id := r.PathValue("id")

	var input domain.UpdateProjectInput
	if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
		WriteError(w, http.StatusBadRequest, fmt.Errorf("JSON inválido: %v", err))
		return
	}

	project, err := h.updateProjectUC.Execute(id, &input)
	if err != nil {
		statusCode := http.StatusInternalServerError
		if domErr, ok := err.(*domain.DomainError); ok {
			if domErr.Code == "PROJECT_NOT_FOUND" {
				statusCode = http.StatusNotFound
			} else {
				statusCode = http.StatusBadRequest
			}
		}

		WriteError(w, statusCode, err)
		return
	}

	contentType := ContentType(r)
	if contentType == "xml" {
		WriteXML(w, http.StatusOK, project)
	} else {
		WriteJSON(w, http.StatusOK, project)
	}
}

// DeleteProject maneja DELETE /proyectos/{id}
// URL Parameter: id del proyecto
// Response: 204 No Content si es exitoso
func (h *Handler) DeleteProject(w http.ResponseWriter, r *http.Request) {
	id := r.PathValue("id")

	err := h.deleteProjectUC.Execute(id)
	if err != nil {
		statusCode := http.StatusInternalServerError
		if domErr, ok := err.(*domain.DomainError); ok {
			if domErr.Code == "PROJECT_NOT_FOUND" {
				statusCode = http.StatusNotFound
			} else {
				statusCode = http.StatusBadRequest
			}
		}

		WriteError(w, statusCode, err)
		return
	}

	// 204 No Content - no necesita body
	w.WriteHeader(http.StatusNoContent)
}

// Health es un endpoint para healthchecks
// Usado por load balancers, Kubernetes, etc
func (h *Handler) Health(w http.ResponseWriter, r *http.Request) {
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(http.StatusOK)
	json.NewEncoder(w).Encode(map[string]interface{}{
		"status": "healthy",
		"timestamp": "2025-12-22T10:30:00Z",
	})
}

¿Qué observas?

  • Separación clara: HTTP está completamente separado de la lógica de negocio
  • Inyección de dependencias: Los use cases se inyectan en el constructor
  • Manejo de errores profesional: Retorna status codes apropiados
  • Flexibilidad de formato: Soporta JSON y XML automáticamente
  • PathValue(): Go 1.22 permite extraer parámetros de rutas de forma nativa

Fase 6: Middleware y CORS

Paso 6: Implementar middleware profesional

En internal/adapter/http/middleware.go:

package http

import (
	"fmt"
	"log"
	"net/http"
	"time"
)

// LoggingMiddleware registra cada request
// Útil para debugging y auditoría
func LoggingMiddleware(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		start := time.Now()

		// Crear un writer que envuelva el original para capturar status code
		wrapped := &responseWriter{ResponseWriter: w, statusCode: http.StatusOK}

		// Log de entrada
		log.Printf(
			"[REQUEST] %s %s from %s",
			r.Method,
			r.RequestURI,
			r.RemoteAddr,
		)

		// Ejecutar handler siguiente
		next.ServeHTTP(wrapped, r)

		// Log de salida
		duration := time.Since(start)
		log.Printf(
			"[RESPONSE] %s %s - Status: %d - Duration: %v",
			r.Method,
			r.RequestURI,
			wrapped.statusCode,
			duration,
		)
	})
}

// responseWriter envuelve http.ResponseWriter para capturar el status code
type responseWriter struct {
	http.ResponseWriter
	statusCode int
}

func (rw *responseWriter) WriteHeader(code int) {
	rw.statusCode = code
	rw.ResponseWriter.WriteHeader(code)
}

// CORSMiddleware agrega headers CORS para permitir requests desde otros orígenes
// IMPORTANTE: En producción, debes ser restrictivo con qué orígenes permites
func CORSMiddleware(allowedOrigins []string) func(http.Handler) http.Handler {
	return func(next http.Handler) http.Handler {
		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			origin := r.Header.Get("Origin")

			// Verificar si el origen está permitido
			isAllowed := false
			for _, allowed := range allowedOrigins {
				if allowed == "*" || origin == allowed {
					isAllowed = true
					break
				}
			}

			if isAllowed {
				w.Header().Set("Access-Control-Allow-Origin", origin)
				w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
				w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
				w.Header().Set("Access-Control-Max-Age", "86400") // 24 horas
			}

			// Manejar preflight requests (OPTIONS)
			if r.Method == http.MethodOptions {
				w.WriteHeader(http.StatusOK)
				return
			}

			next.ServeHTTP(w, r)
		})
	}
}

// AuthMiddleware valida un token en el header Authorization
// Este es un ejemplo simple, en producción usarías JWT
func AuthMiddleware(expectedToken string) func(http.Handler) http.Handler {
	return func(next http.Handler) http.Handler {
		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			authHeader := r.Header.Get("Authorization")

			if authHeader == "" {
				WriteError(w, http.StatusUnauthorized, fmt.Errorf("Authorization header requerido"))
				return
			}

			// Esperar formato: "Bearer TOKEN"
			if len(authHeader) < 7 || authHeader[:7] != "Bearer " {
				WriteError(w, http.StatusUnauthorized, fmt.Errorf("Formato de Authorization inválido"))
				return
			}

			token := authHeader[7:]
			if token != expectedToken {
				WriteError(w, http.StatusUnauthorized, fmt.Errorf("Token inválido"))
				return
			}

			next.ServeHTTP(w, r)
		})
	}
}

// ContentTypeValidationMiddleware verifica que el Content-Type sea válido para POST/PUT
func ContentTypeValidationMiddleware(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		// Solo validar para requests con body
		if r.Method == http.MethodPost || r.Method == http.MethodPut {
			contentType := r.Header.Get("Content-Type")

			// Permitir JSON y XML
			if contentType != "application/json" && contentType != "application/xml" {
				WriteError(
					w,
					http.StatusUnsupportedMediaType,
					fmt.Errorf("Content-Type debe ser application/json o application/xml"),
				)
				return
			}
		}

		next.ServeHTTP(w, r)
	})
}

// RecoveryMiddleware recupera de panics y retorna un error 500
// Previene que un panic derribe toda la aplicación
func RecoveryMiddleware(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		defer func() {
			if err := recover(); err != nil {
				log.Printf("[PANIC] %v", err)
				WriteError(w, http.StatusInternalServerError, fmt.Errorf("error interno del servidor"))
			}
		}()

		next.ServeHTTP(w, r)
	})
}

// RateLimitMiddleware implementa un rate limiter simple por IP
// En producción, considera usar una librería dedicada
type RateLimiter struct {
	requestCounts map[string]int
	requestTimes  map[string]time.Time
	limit         int
	window        time.Duration
}

func NewRateLimiter(limit int, window time.Duration) *RateLimiter {
	return &RateLimiter{
		requestCounts: make(map[string]int),
		requestTimes:  make(map[string]time.Time),
		limit:         limit,
		window:        window,
	}
}

func (rl *RateLimiter) Middleware(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		ip := r.RemoteAddr

		now := time.Now()
		if lastTime, exists := rl.requestTimes[ip]; exists {
			// Si es dentro de la ventana, incrementa contador
			if now.Sub(lastTime) < rl.window {
				rl.requestCounts[ip]++

				if rl.requestCounts[ip] > rl.limit {
					WriteError(w, http.StatusTooManyRequests, fmt.Errorf("límite de requests excedido"))
					return
				}
			} else {
				// Ventana expiró, resetea
				rl.requestCounts[ip] = 1
				rl.requestTimes[ip] = now
			}
		} else {
			// Primera request
			rl.requestCounts[ip] = 1
			rl.requestTimes[ip] = now
		}

		next.ServeHTTP(w, r)
	})
}

¿Qué hace cada middleware?

  1. LoggingMiddleware: Registra cada request y respuesta (timing, status code)
  2. CORSMiddleware: Permite requests desde otros dominios (configurable)
  3. AuthMiddleware: Valida token en Authorization header
  4. ContentTypeValidationMiddleware: Rechaza requests con Content-Type inválido
  5. RecoveryMiddleware: Recupera de panics (no deja que derriben la app)
  6. RateLimitMiddleware: Limita requests por IP (protege contra abuso)

Fase 7: Enrutamiento Completo

Paso 7: Configurar rutas y middleware

En cmd/api/main.go:

package main

import (
	"fmt"
	"log"
	"net/http"
	"os"
	"time"

	"github.com/tuusuario/proyecto-api/internal/adapter/http"
	"github.com/tuusuario/proyecto-api/internal/adapter/storage"
	"github.com/tuusuario/proyecto-api/internal/usecase"
)

func main() {
	// ==========================================
	// 1. SETUP: Inicializar adapters y use cases
	// ==========================================

	// Storage (en memoria)
	projectStorage := storage.NewInMemoryProjectStorage()

	// Use cases
	createProjectUC := usecase.NewCreateProjectUseCase(projectStorage)
	listProjectsUC := usecase.NewListProjectsUseCase(projectStorage)
	getProjectUC := usecase.NewGetProjectUseCase(projectStorage)
	updateProjectUC := usecase.NewUpdateProjectUseCase(projectStorage)
	deleteProjectUC := usecase.NewDeleteProjectUseCase(projectStorage)

	// HTTP Handler
	handler := http.NewHandler(
		createProjectUC,
		listProjectsUC,
		getProjectUC,
		updateProjectUC,
		deleteProjectUC,
	)

	// ==========================================
	// 2. CREAR ENRUTADOR
	// ==========================================

	mux := http.NewServeMux()

	// Health check (sin autenticación)
	mux.HandleFunc("GET /health", handler.Health)

	// Rutas de proyectos
	// POST /proyectos - crear
	mux.HandleFunc("POST /proyectos", handler.CreateProject)

	// GET /proyectos - listar todos
	mux.HandleFunc("GET /proyectos", handler.ListProjects)

	// GET /proyectos/{id} - obtener uno
	// Nota: Go 1.22 permite {id} en patrones de ServeMux
	mux.HandleFunc("GET /proyectos/{id}", handler.GetProject)

	// PUT /proyectos/{id} - actualizar
	mux.HandleFunc("PUT /proyectos/{id}", handler.UpdateProject)

	// DELETE /proyectos/{id} - eliminar
	mux.HandleFunc("DELETE /proyectos/{id}", handler.DeleteProject)

	// ==========================================
	// 3. APLICAR MIDDLEWARE EN ORDEN
	// ==========================================

	// El orden importa: primero recovery, luego logging, luego CORS, luego auth

	var finalHandler http.Handler = mux

	// Recovery middleware (primero, para capturar panics)
	finalHandler = http.MaxBytesHandler(finalHandler, 10*1024*1024) // Limita body a 10MB
	finalHandler = withRecovery(finalHandler)

	// Logging (se aplica a todos)
	finalHandler = http.HandlerFunc(loggingMiddleware).ServeHTTP // Usando función simple

	// CORS
	finalHandler = http.HandlerFunc(corsMiddleware).ServeHTTP

	// Content Type Validation
	finalHandler = http.HandlerFunc(contentTypeValidationMiddleware).ServeHTTP

	// ==========================================
	// 4. INICIAR SERVIDOR
	// ==========================================

	port := getEnv("PORT", "8080")
	addr := fmt.Sprintf(":%s", port)

	server := &http.Server{
		Addr:         addr,
		Handler:      finalHandler,
		ReadTimeout:  15 * time.Second,
		WriteTimeout: 15 * time.Second,
		IdleTimeout:  60 * time.Second,
	}

	log.Printf("🚀 Servidor iniciando en http://localhost:%s", port)
	log.Printf("📊 Endpoints disponibles:")
	log.Printf("  GET    /health")
	log.Printf("  POST   /proyectos")
	log.Printf("  GET    /proyectos")
	log.Printf("  GET    /proyectos/{id}")
	log.Printf("  PUT    /proyectos/{id}")
	log.Printf("  DELETE /proyectos/{id}")

	if err := server.ListenAndServe(); err != nil {
		log.Fatalf("❌ Error iniciando servidor: %v", err)
	}
}

// Middleware helpers simples

func withRecovery(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		defer func() {
			if err := recover(); err != nil {
				log.Printf("[PANIC] %v", err)
				http.Error(w, "error interno", http.StatusInternalServerError)
			}
		}()
		next.ServeHTTP(w, r)
	})
}

func loggingMiddleware(w http.ResponseWriter, r *http.Request) {
	start := time.Now()
	log.Printf("[%s] %s %s", r.Method, r.RequestURI, time.Since(start))
}

func corsMiddleware(w http.ResponseWriter, r *http.Request) {
	w.Header().Set("Access-Control-Allow-Origin", "*")
	w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
	w.Header().Set("Access-Control-Allow-Headers", "Content-Type")

	if r.Method == http.MethodOptions {
		w.WriteHeader(http.StatusOK)
		return
	}
}

func contentTypeValidationMiddleware(w http.ResponseWriter, r *http.Request) {
	if r.Method == http.MethodPost || r.Method == http.MethodPut {
		ct := r.Header.Get("Content-Type")
		if ct != "application/json" && ct != "application/xml" {
			http.Error(w, "Content-Type debe ser application/json", http.StatusUnsupportedMediaType)
			return
		}
	}
}

func getEnv(key, defaultVal string) string {
	if val := os.Getenv(key); val != "" {
		return val
	}
	return defaultVal
}

Fase 8: Testear la API

Paso 8: Probar con curl

Primero, inicia el servidor:

cd cmd/api
go run main.go

Verás:

🚀 Servidor iniciando en http://localhost:8080
📊 Endpoints disponibles:
  GET    /health
  POST   /proyectos
  GET    /proyectos
  GET    /proyectos/{id}
  PUT    /proyectos/{id}
  DELETE /proyectos/{id}

Prueba 1: Health check

curl http://localhost:8080/health

Respuesta:

{
  "status": "healthy",
  "timestamp": "2025-12-22T10:30:00Z"
}

Prueba 2: Crear proyecto (JSON)

curl -X POST http://localhost:8080/proyectos \
  -H "Content-Type: application/json" \
  -d '{
    "title": "Construir API en Go",
    "description": "Aprender a hacer APIs REST nativas"
  }'

Respuesta:

{
  "status": "success",
  "code": 201,
  "data": {
    "id": "550e8400-e29b-41d4-a716-446655440000",
    "title": "Construir API en Go",
    "description": "Aprender a hacer APIs REST nativas",
    "status": "pendiente",
    "created_at": "2025-12-22T10:30:00Z",
    "updated_at": "2025-12-22T10:30:00Z"
  }
}

Prueba 3: Crear proyecto (XML)

curl -X POST http://localhost:8080/proyectos \
  -H "Content-Type: application/json" \
  -H "Accept: application/xml" \
  -d '{
    "title": "Aprender XML",
    "description": "Entender serialización XML"
  }'

Respuesta XML:

<?xml version="1.0" encoding="UTF-8"?>
<response>
  <status>success</status>
  <code>201</code>
  <data>
    <id>...</id>
    <title>Aprender XML</title>
    ...
  </data>
</response>

Prueba 4: Listar proyectos

curl http://localhost:8080/proyectos

Respuesta:

{
  "status": "success",
  "code": 200,
  "data": [
    {
      "id": "550e8400-...",
      "title": "Construir API en Go",
      ...
    }
  ]
}

Prueba 5: Obtener proyecto específico

curl http://localhost:8080/proyectos/550e8400-e29b-41d4-a716-446655440000

Prueba 6: Actualizar proyecto

curl -X PUT http://localhost:8080/proyectos/550e8400-e29b-41d4-a716-446655440000 \
  -H "Content-Type: application/json" \
  -d '{
    "status": "en-progreso",
    "title": "Aprender Go REST APIs"
  }'

Prueba 7: Eliminar proyecto

curl -X DELETE http://localhost:8080/proyectos/550e8400-e29b-41d4-a716-446655440000

Respuesta: 204 No Content (sin body)


Patrones Avanzados

Validación Profesional

En internal/domain/validation.go:

package domain

import (
	"fmt"
	"regexp"
	"strings"
)

// Validator agrupa funciones de validación
type Validator struct {
	errors map[string]string
}

func NewValidator() *Validator {
	return &Validator{
		errors: make(map[string]string),
	}
}

func (v *Validator) IsEmpty(field, value string) *Validator {
	if strings.TrimSpace(value) == "" {
		v.errors[field] = fmt.Sprintf("%s es requerido", field)
	}
	return v
}

func (v *Validator) MaxLength(field, value string, max int) *Validator {
	if len(value) > max {
		v.errors[field] = fmt.Sprintf("%s no puede exceder %d caracteres", field, max)
	}
	return v
}

func (v *Validator) MinLength(field, value string, min int) *Validator {
	if len(value) < min {
		v.errors[field] = fmt.Sprintf("%s debe tener al menos %d caracteres", field, min)
	}
	return v
}

func (v *Validator) Matches(field, value, pattern string) *Validator {
	regex := regexp.MustCompile(pattern)
	if !regex.MatchString(value) {
		v.errors[field] = fmt.Sprintf("%s tiene formato inválido", field)
	}
	return v
}

func (v *Validator) Valid() bool {
	return len(v.errors) == 0
}

func (v *Validator) Errors() map[string]string {
	return v.errors
}

Úsalo en use cases:

func (uc *CreateProjectUseCase) Execute(input *domain.CreateProjectInput) (*domain.Project, error) {
	validator := domain.NewValidator()

	validator.IsEmpty("title", input.Title).
		MaxLength("title", input.Title, 255).
		MaxLength("description", input.Description, 2000)

	if !validator.Valid() {
		return nil, domain.NewInvalidProjectError(
			fmt.Sprintf("validación fallida: %v", validator.Errors()),
		)
	}

	// ... resto del código
}

Error Handling Profesional

// En domain/errors.go, expande:

type ErrorHandler interface {
	Handle(error) (statusCode int, response interface{})
}

type DefaultErrorHandler struct{}

func (h *DefaultErrorHandler) Handle(err error) (int, interface{}) {
	switch err.(type) {
	case *DomainError:
		domErr := err.(*DomainError)
		if domErr.Code == "PROJECT_NOT_FOUND" {
			return 404, map[string]interface{}{
				"error": "No encontrado",
				"code": domErr.Code,
			}
		}
		return 400, map[string]interface{}{
			"error": domErr.Message,
			"code": domErr.Code,
		}
	default:
		return 500, map[string]interface{}{
			"error": "Error interno del servidor",
		}
	}
}

Despliegue y Compilación para Producción

Compilar Binarios Optimizados

# Para Linux 64-bit (el más común en servidores)
GOOS=linux GOARCH=amd64 go build -o proyecto-api-linux cmd/api/main.go

# Para Windows
GOOS=windows GOARCH=amd64 go build -o proyecto-api.exe cmd/api/main.go

# Para macOS (Intel)
GOOS=darwin GOARCH=amd64 go build -o proyecto-api-mac cmd/api/main.go

# Para macOS (Apple Silicon)
GOOS=darwin GOARCH=arm64 go build -o proyecto-api-mac-arm cmd/api/main.go

Compilación Optimizada para Tamaño

# Reduce tamaño del binario eliminando símbolos de debug
go build -ldflags="-s -w" -o proyecto-api cmd/api/main.go

# Comprime aún más con upx (si lo tienes instalado)
upx --best proyecto-api

Usando Docker

Crear Dockerfile:

# Build stage
FROM golang:1.25-alpine AS builder

WORKDIR /app

COPY go.mod go.sum ./
RUN go mod download

COPY . .

RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o proyecto-api cmd/api/main.go

# Runtime stage
FROM alpine:latest

RUN apk --no-cache add ca-certificates

WORKDIR /root/

COPY --from=builder /app/proyecto-api .

EXPOSE 8080

CMD ["./proyecto-api"]

Build y run:

docker build -t proyecto-api:latest .
docker run -p 8080:8080 proyecto-api:latest

Conclusión: Clean Architecture en Go HTTP

Lo que acabas de ver no es “usar Go para APIs REST”. Es entender arquitectura limpia en el contexto del protocolo HTTP. Cada decisión que tomamos:

  • Separar dominio de HTTP: Tu negocio no conoce de HTTP
  • Inyección de dependencias: Los handlers reciben sus dependencias
  • Use cases pequeños: Una responsabilidad por use case
  • Middleware reusable: Funciones que envuelven handlers
  • Manejo de errores profesional: Status codes apropiados
  • Serialización flexible: JSON y XML sin duplicar lógica

Te permite:

Testar sin servidor HTTP
Cambiar de JSON a XML sin tocar lógica
Cambiar de almacenamiento en memoria a base de datos sin tocar handlers
Reusar use cases en CLI, gRPC, GraphQL, lo que sea
Onboardear nuevos desarrolladores rápidamente
Mantener código sin que la entropía lo derrumbe

Go no necesita frameworks para construir APIs magníficas. Necesita disciplina, entendimiento de arquitectura, y respeto por la simplicidad que el lenguaje promueve.

Lo que aprendiste aquí es aplicable a cualquier escala: desde un microservicio pequeño hasta una aplicación empresarial compleja. Los principios son los mismos. Solo crece la complejidad, no el caos.

Ahora, construye.

Tags

#golang #rest-api #architecture #middleware #clean-architecture #go-1.25