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.
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?
- Seguridad: El cliente no puede manipular
IDo timestamps - Flexibilidad: Puedes cambiar qué requiere creación sin afectar la entidad
- 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.RWMutexpara 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?
- LoggingMiddleware: Registra cada request y respuesta (timing, status code)
- CORSMiddleware: Permite requests desde otros dominios (configurable)
- AuthMiddleware: Valida token en Authorization header
- ContentTypeValidationMiddleware: Rechaza requests con Content-Type inválido
- RecoveryMiddleware: Recupera de panics (no deja que derriben la app)
- 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
Artículos relacionados
API Versioning Strategies: Cómo Evolucionar APIs sin Romper Clientes
Una guía exhaustiva sobre estrategias de versionado de APIs: URL versioning vs Header versioning, cómo deprecar endpoints sin shock, migration patterns reales, handling de cambios backwards-incompatibles, y decisiones arquitectónicas que importan. Con 50+ ejemplos de código en Go.
Arquitectura de software: Más allá del código
Una guía completa sobre arquitectura de software explicada en lenguaje humano: patrones, organización, estructura y cómo construir sistemas que escalen con tu negocio.
Automatizando tu vida con Go CLI: Guía profesional para crear herramientas de línea de comandos escalables
Una guía exhaustiva y paso a paso sobre cómo crear herramientas CLI escalables con Go 1.25.5: desde lo básico hasta proyectos empresariales complejos con flags, configuración, logging, y ejemplos prácticos para Windows y Linux.