Flujo Profesional de Desarrollo: API de Notas en Go con Uber Fx, Gin, GORM y Stack de Industria
Segunda parte del flujo de construcción de la API de notas: migramos de la stdlib pura a un stack de industria con Uber Fx (inyección de dependencias), Gin, GORM, UUID, Zap, Viper y Air. Arquitectura Hexagonal y DDD intactas, wiring declarativo y graceful shutdown vía fx.Lifecycle.
Flujo Profesional de Desarrollo: API de Notas con Stack de Industria
Go + Uber Fx + Gin + GORM + UUID + DevTools — De Cero a Producción
Cambio de paradigma (Termodinámica del software): En el flujo anterior éramos un sistema cerrado (cero entropía externa, todo manual). Ahora abrimos el sistema a paquetes maduros. Cedemos control a cambio de energía libre: menos boilerplate, más velocidad. El arte está en elegir dependencias que reduzcan entropía neta, no que la importen.
Stack Tecnológico Seleccionado
| Categoría | Paquete | Por qué este y no otro |
|---|---|---|
| DI Framework | go.uber.org/fx | Ciclo de vida (lifecycle) + grafo de dependencias automático |
| HTTP Router | github.com/gin-gonic/gin | Performance, middleware ecosystem, binding/validación nativa |
| ORM | gorm.io/gorm + gorm.io/driver/sqlite | Migraciones automáticas, hooks, menos SQL manual |
| IDs | github.com/google/uuid | UUID v4/v7 estándar, sin reinventar crypto/rand |
| Validación | github.com/go-playground/validator (vía Gin) | Declarativa con struct tags |
| Logging | go.uber.org/zap | Estructurado, alto rendimiento, integra con Fx |
| Config | github.com/spf13/viper | Env + archivos + flags unificados |
| Hot Reload | github.com/air-verse/air (devtool) | Recompilación automática en desarrollo |
| Linting | golangci-lint (devtool) | Meta-linter estándar de la industria |
Decisión arquitectónica clave: Mantenemos Hexagonal + DDD. Fx no reemplaza la arquitectura; la cablea. Gin y GORM viven en la capa de infraestructura. El dominio sigue siendo Go puro, sin imports de terceros.
Fase 0: Mentalidad — El Grafo de Dependencias como Sistema Físico
Uber Fx transforma el “wiring manual” del main.go anterior en un grafo dirigido acíclico (DAG) resuelto automáticamente.
$$G = (V, E) \quad \text{donde} \quad V = \text{providers}, \quad E = \text{dependencias}$$
Fx hace topological sort del grafo: construye cada nodo solo cuando sus dependencias existen. Es como el principio de causalidad: ningún efecto precede a su causa.
┌─────────────┐
│ fx.App │ ← Resuelve el DAG completo
└──────┬──────┘
┌────────────────┼────────────────┐
▼ ▼ ▼
┌──────────┐ ┌────────────┐ ┌───────────┐
│ Config │ │ Logger │ │ *gorm.DB │
└────┬─────┘ └─────┬──────┘ └─────┬─────┘
│ │ │
└────────────────┼────────────────┘
▼
┌───────────────┐
│ NoteRepository │ (driven adapter)
└───────┬────────┘
▼
┌───────────────┐
│ NoteService │ (application)
└───────┬────────┘
▼
┌───────────────┐
│ NoteHandler │ (driving adapter)
└───────┬────────┘
▼
┌───────────────┐
│ *gin.Engine │ → HTTP Server (lifecycle hook)
└───────────────┘
Principio Cyberpunk: “En Night City, no construyes los implantes desde el silicio — los ensamblas. Pero el ripperdoc que conoce cómo encajan domina la calle.” Fx es tu ripperdoc: ensambla, tú diseñas.
Fase 1: Inicialización del Proyecto
Paso 1.1 — Crear y entrar al directorio
mkdir notes-api-pro && cd notes-api-pro
Paso 1.2 — Inicializar módulo
go mod init github.com/tu-usuario/notes-api-pro
Paso 1.3 — Verificar Go 1.22+
go version
Fase 2: Instalación del Stack
Paso 2.1 — Dependencias core
go get go.uber.org/fx@latest
go get go.uber.org/zap@latest
go get github.com/gin-gonic/gin@latest
go get gorm.io/gorm@latest
go get gorm.io/driver/sqlite@latest
go get github.com/google/uuid@latest
go get github.com/spf13/viper@latest
Paso 2.2 — Instalar DevTools (binarios globales)
# Hot reload — recompila al guardar
go install github.com/air-verse/air@latest
# Meta-linter de la industria
go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest
# Visualizador de dependencias / formateo de imports
go install golang.org/x/tools/cmd/goimports@latest
Nota Arch/CachyOS: Asegúrate de tener
$(go env GOPATH)/binen tu$PATH. En tu~/.config/fish/config.fisho~/.zshrc:export PATH="$PATH:$(go env GOPATH)/bin"
Fase 3: Git + Configuración de DevTools
Paso 3.1 — Inicializar Git
git init
Paso 3.2 — .gitignore
cat > .gitignore << 'EOF'
/bin/
/tmp/
*.exe
*.db
*.sqlite
*.sqlite3
.env
.env.local
.idea/
.vscode/
*.swp
# Air
tmp/
build-errors.log
EOF
Paso 3.3 — Configurar Air para hot reload (.air.toml)
cat > .air.toml << 'EOF'
root = "."
tmp_dir = "tmp"
[build]
# Compila el binario a tmp/ y lo ejecuta
cmd = "go build -o ./tmp/main ./cmd/api"
bin = "./tmp/main"
# Recompila al detectar cambios en estos tipos de archivo
include_ext = ["go", "yaml", "yml"]
exclude_dir = ["tmp", "bin", "vendor"]
delay = 1000
stop_on_error = true
[log]
time = true
[misc]
clean_on_exit = true
EOF
Paso 3.4 — Configurar golangci-lint (.golangci.yml)
cat > .golangci.yml << 'EOF'
run:
timeout: 3m
linters:
enable:
- errcheck # Detecta errores no manejados
- govet # Análisis estático de Go
- staticcheck # El linter más completo
- revive # Reemplazo moderno de golint
- gosimple # Sugiere simplificaciones
- ineffassign # Asignaciones inútiles
- unused # Código muerto
- gofmt
- goimports
issues:
exclude-use-default: false
EOF
Paso 3.5 — Primer commit
git add .gitignore .air.toml .golangci.yml go.mod go.sum
git commit -m "chore: bootstrap project with industry stack and devtools"
Fase 4: Estructura de Carpetas
mkdir -p cmd/api
mkdir -p internal/domain/note
mkdir -p internal/application
mkdir -p internal/infrastructure/persistence
mkdir -p internal/infrastructure/http
mkdir -p internal/config
mkdir -p internal/platform/logger
Estructura resultante (organizada por módulos Fx):
notes-api-pro/
├── cmd/
│ └── api/
│ └── main.go # fx.New(...) — el grafo
├── internal/
│ ├── domain/note/
│ │ ├── note.go # Entity (Go PURO, sin libs)
│ │ ├── repository.go # Port
│ │ └── errors.go
│ ├── application/
│ │ ├── note_service.go # Use cases
│ │ └── module.go # fx.Module de application
│ ├── infrastructure/
│ │ ├── persistence/
│ │ │ ├── note_model.go # GORM model + mapeo
│ │ │ ├── note_repo.go # Driven adapter (GORM)
│ │ │ ├── connection.go # *gorm.DB provider
│ │ │ └── module.go # fx.Module de persistence
│ │ └── http/
│ │ ├── note_handler.go # Driving adapter (Gin)
│ │ ├── router.go # Server + lifecycle hook
│ │ ├── error_mapper.go # Domain error -> HTTP
│ │ └── module.go # fx.Module de http
│ ├── config/
│ │ ├── config.go # Viper
│ │ └── module.go
│ └── platform/logger/
│ └── module.go # Zap provider
├── .air.toml
├── .golangci.yml
└── go.mod
Patrón clave —
module.gopor capa: Cada paquete expone unfx.Moduleque agrupa sus providers. Elmain.gosolo compone módulos, no providers individuales. Es composición de composiciones — fractal.
Fase 5: Dominio (Idéntico al flujo puro — esa es la gracia)
El dominio NO cambia. Aunque usemos Gin/GORM/Fx, el núcleo permanece libre de dependencias. Esta es la prueba de fuego de la arquitectura hexagonal: los frameworks son detalles intercambiables.
Paso 5.1 — internal/domain/note/errors.go
package note
import "errors"
var (
ErrNotFound = errors.New("note not found")
ErrEmptyTitle = errors.New("note title cannot be empty")
ErrTitleTooLong = errors.New("note title exceeds maximum length")
ErrInvalidID = errors.New("invalid note id")
)
Paso 5.2 — internal/domain/note/note.go
package note
import (
"strings"
"time"
)
const (
maxTitleLength = 200
maxContentLength = 10000
)
// Note: Entidad raíz del agregado. Go puro, cero dependencias externas.
type Note struct {
id string
title string
content string
createdAt time.Time
updatedAt time.Time
}
// New construye una Note válida. El ID se inyecta desde fuera
// (la generación de UUID es responsabilidad de la aplicación, no del dominio:
// el dominio no debe conocer "github.com/google/uuid").
func New(id, title, content string) (*Note, error) {
title = strings.TrimSpace(title)
if err := validateTitle(title); err != nil {
return nil, err
}
if err := validateContent(content); err != nil {
return nil, err
}
now := time.Now().UTC()
return &Note{
id: id,
title: title,
content: content,
createdAt: now,
updatedAt: now,
}, nil
}
// Reconstitute: rehidrata desde persistencia sin re-validar.
func Reconstitute(id, title, content string, createdAt, updatedAt time.Time) *Note {
return &Note{
id: id, title: title, content: content,
createdAt: createdAt, updatedAt: updatedAt,
}
}
func (n *Note) UpdateContent(title, content string) error {
title = strings.TrimSpace(title)
if err := validateTitle(title); err != nil {
return err
}
if err := validateContent(content); err != nil {
return err
}
n.title = title
n.content = content
n.updatedAt = time.Now().UTC()
return nil
}
func (n *Note) ID() string { return n.id }
func (n *Note) Title() string { return n.title }
func (n *Note) Content() string { return n.content }
func (n *Note) CreatedAt() time.Time { return n.createdAt }
func (n *Note) UpdatedAt() time.Time { return n.updatedAt }
func validateTitle(title string) error {
if title == "" {
return ErrEmptyTitle
}
if len(title) > maxTitleLength {
return ErrTitleTooLong
}
return nil
}
func validateContent(content string) error {
if len(content) > maxContentLength {
return ErrTitleTooLong
}
return nil
}
Paso 5.3 — internal/domain/note/repository.go
package note
import "context"
// Repository: el puerto. El dominio define el contrato.
type Repository interface {
Save(ctx context.Context, n *Note) error
FindByID(ctx context.Context, id string) (*Note, error)
FindAll(ctx context.Context) ([]*Note, error)
Delete(ctx context.Context, id string) error
}
Paso 5.4 — Commit
git add internal/domain
git commit -m "feat(domain): add Note entity and port (framework-agnostic)"
Fase 6: Platform — Logger con Zap + Fx
Filosofía Fx: Todo lo que “se provee” es un constructor que Fx invoca. Aquí proveemos un
*zap.Loggerque el resto del grafo consumirá.
Paso 6.1 — internal/platform/logger/module.go
package logger
import (
"go.uber.org/fx"
"go.uber.org/fx/fxevent"
"go.uber.org/zap"
)
// Module exporta el provider del logger y conecta los logs internos de Fx.
var Module = fx.Options(
fx.Provide(NewLogger),
// Redirige los eventos del ciclo de vida de Fx a nuestro Zap logger.
// Sin esto, Fx loguea con su formato por defecto.
fx.WithLogger(func(log *zap.Logger) fxevent.Logger {
return &fxevent.ZapLogger{Logger: log}
}),
)
// NewLogger construye un logger estructurado de producción.
// En un caso real, leerías el nivel desde config (Development vs Production).
func NewLogger() (*zap.Logger, error) {
// zap.NewProduction: JSON, nivel Info, con timestamps y caller.
log, err := zap.NewProduction()
if err != nil {
return nil, err
}
return log, nil
}
Fase 7: Config con Viper + Fx
Paso 7.1 — internal/config/config.go
package config
import (
"fmt"
"strings"
"github.com/spf13/viper"
)
// Config: estructura tipada de toda la configuración.
// Las tags mapstructure permiten a Viper deserializar env/files.
type Config struct {
Server ServerConfig `mapstructure:"server"`
Database DatabaseConfig `mapstructure:"database"`
}
type ServerConfig struct {
Port string `mapstructure:"port"`
Mode string `mapstructure:"mode"` // gin: debug | release | test
}
type DatabaseConfig struct {
DSN string `mapstructure:"dsn"`
}
// New carga la configuración con precedencia: ENV > defaults.
// Viper unifica múltiples fuentes en una sola API.
func New() (*Config, error) {
v := viper.New()
// Defaults sensatos
v.SetDefault("server.port", "8080")
v.SetDefault("server.mode", "release")
v.SetDefault("database.dsn", "notes.db")
// Lee variables de entorno: SERVER_PORT, DATABASE_DSN, etc.
// El replacer convierte "server.port" -> "SERVER_PORT".
v.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
v.AutomaticEnv()
var cfg Config
if err := v.Unmarshal(&cfg); err != nil {
return nil, fmt.Errorf("config: failed to unmarshal: %w", err)
}
return &cfg, nil
}
Paso 7.2 — internal/config/module.go
package config
import "go.uber.org/fx"
// Module provee la configuración al grafo de Fx.
var Module = fx.Options(
fx.Provide(New),
)
Fase 8: Persistencia con GORM + Fx
Cambio fundamental vs flujo puro: GORM elimina el SQL manual y el
scanNote. A cambio, introducimos un modelo de persistencia (NoteModel) separado de la entidad de dominio. Nunca uses la entidad de dominio como modelo GORM directamente — eso acopla tu núcleo al ORM.
Paso 8.1 — Modelo GORM y mapeo (internal/infrastructure/persistence/note_model.go)
package persistence
import (
"time"
"github.com/tu-usuario/notes-api-pro/internal/domain/note"
)
// NoteModel es el modelo de PERSISTENCIA (no de dominio).
// Las tags gorm describen el schema. Este struct vive SOLO en infraestructura.
//
// Separar modelo de persistencia de entidad de dominio (Data Mapper pattern)
// evita que decisiones del ORM (tags, hooks, índices) contaminen el núcleo.
type NoteModel struct {
ID string `gorm:"primaryKey;type:text"`
Title string `gorm:"type:text;not null;index"`
Content string `gorm:"type:text"`
CreatedAt time.Time `gorm:"not null"`
UpdatedAt time.Time `gorm:"not null"`
}
// TableName fija el nombre de tabla explícitamente (evita pluralización mágica).
func (NoteModel) TableName() string {
return "notes"
}
// toDomain mapea el modelo de DB -> entidad de dominio.
// Usa Reconstitute porque los datos ya son válidos.
func (m NoteModel) toDomain() *note.Note {
return note.Reconstitute(m.ID, m.Title, m.Content, m.CreatedAt, m.UpdatedAt)
}
// fromDomain mapea entidad de dominio -> modelo de DB.
func fromDomain(n *note.Note) NoteModel {
return NoteModel{
ID: n.ID(),
Title: n.Title(),
Content: n.Content(),
CreatedAt: n.CreatedAt(),
UpdatedAt: n.UpdatedAt(),
}
}
Paso 8.2 — Conexión y migración (internal/infrastructure/persistence/connection.go)
package persistence
import (
"fmt"
"github.com/tu-usuario/notes-api-pro/internal/config"
"go.uber.org/zap"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
gormlogger "gorm.io/gorm/logger"
)
// NewDatabase provee el *gorm.DB al grafo de Fx.
// Depende de Config y Logger: Fx inyecta ambos automáticamente.
func NewDatabase(cfg *config.Config, log *zap.Logger) (*gorm.DB, error) {
db, err := gorm.Open(sqlite.Open(cfg.Database.DSN), &gorm.Config{
// Silenciamos el logger ruidoso de GORM en producción.
Logger: gormlogger.Default.LogMode(gormlogger.Warn),
})
if err != nil {
return nil, fmt.Errorf("persistence: failed to open db: %w", err)
}
// AutoMigrate crea/actualiza el schema según el modelo.
// En producción seria usarías migraciones versionadas (golang-migrate),
// pero para CRUD y desarrollo rápido, AutoMigrate es pragmático.
if err := db.AutoMigrate(&NoteModel{}); err != nil {
return nil, fmt.Errorf("persistence: automigrate failed: %w", err)
}
// SQLite: serializar escrituras para evitar "database is locked".
sqlDB, err := db.DB()
if err != nil {
return nil, fmt.Errorf("persistence: failed to get sql.DB: %w", err)
}
sqlDB.SetMaxOpenConns(1)
log.Info("database connection established", zap.String("dsn", cfg.Database.DSN))
return db, nil
}
Paso 8.3 — Repository GORM (internal/infrastructure/persistence/note_repo.go)
package persistence
import (
"context"
"errors"
"fmt"
"github.com/tu-usuario/notes-api-pro/internal/domain/note"
"gorm.io/gorm"
)
// Verificación en compile-time de la implementación del puerto.
var _ note.Repository = (*NoteRepository)(nil)
// NoteRepository: driven adapter con GORM.
type NoteRepository struct {
db *gorm.DB
}
func NewNoteRepository(db *gorm.DB) *NoteRepository {
return &NoteRepository{db: db}
}
// Save: upsert. GORM clause OnConflict maneja insert-or-update.
func (r *NoteRepository) Save(ctx context.Context, n *note.Note) error {
model := fromDomain(n)
// WithContext propaga cancelación: si el cliente corta, la query se aborta.
// Save de GORM hace upsert por primary key automáticamente.
if err := r.db.WithContext(ctx).Save(&model).Error; err != nil {
return fmt.Errorf("persistence: save failed: %w", err)
}
return nil
}
func (r *NoteRepository) FindByID(ctx context.Context, id string) (*note.Note, error) {
var model NoteModel
err := r.db.WithContext(ctx).First(&model, "id = ?", id).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
// Traducción: error de GORM -> error de DOMINIO.
return nil, note.ErrNotFound
}
if err != nil {
return nil, fmt.Errorf("persistence: find failed: %w", err)
}
return model.toDomain(), nil
}
func (r *NoteRepository) FindAll(ctx context.Context) ([]*note.Note, error) {
var models []NoteModel
if err := r.db.WithContext(ctx).Order("created_at DESC").Find(&models).Error; err != nil {
return nil, fmt.Errorf("persistence: findall failed: %w", err)
}
notes := make([]*note.Note, 0, len(models))
for _, m := range models {
notes = append(notes, m.toDomain())
}
return notes, nil
}
func (r *NoteRepository) Delete(ctx context.Context, id string) error {
res := r.db.WithContext(ctx).Delete(&NoteModel{}, "id = ?", id)
if res.Error != nil {
return fmt.Errorf("persistence: delete failed: %w", res.Error)
}
if res.RowsAffected == 0 {
return note.ErrNotFound
}
return nil
}
Paso 8.4 — Módulo Fx de persistencia (internal/infrastructure/persistence/module.go)
package persistence
import (
"github.com/tu-usuario/notes-api-pro/internal/domain/note"
"go.uber.org/fx"
)
// Module provee la DB y el repositorio.
var Module = fx.Options(
fx.Provide(NewDatabase),
// Patrón CLAVE de Fx: proveemos el repo COMO la interfaz del puerto.
// fx.As le dice a Fx: "cuando alguien pida note.Repository, dale *NoteRepository".
// Esto materializa la Inversión de Dependencias en el grafo de DI.
fx.Provide(
fx.Annotate(
NewNoteRepository,
fx.As(new(note.Repository)),
),
),
)
El truco de
fx.As: Sin esto, Fx inyectaría el tipo concreto*NoteRepository. Confx.As(new(note.Repository)), el grafo expone la interfaz. ElNoteServicepidenote.Repositoryy Fx resuelve la implementación. Inversión de dependencias declarativa.
Fase 9: Aplicación con Fx
Paso 9.1 — internal/application/note_service.go
package application
import (
"context"
"fmt"
"github.com/google/uuid"
"github.com/tu-usuario/notes-api-pro/internal/domain/note"
)
// DTOs de la frontera de aplicación
type CreateNoteInput struct {
Title string
Content string
}
type UpdateNoteInput struct {
ID string
Title string
Content string
}
type NoteOutput struct {
ID string `json:"id"`
Title string `json:"title"`
Content string `json:"content"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
// NoteService depende del PUERTO. Fx inyectará la implementación GORM.
type NoteService struct {
repo note.Repository
}
// NewNoteService: constructor que Fx invoca. El parámetro note.Repository
// se resuelve automáticamente gracias a fx.As en el módulo de persistencia.
func NewNoteService(repo note.Repository) *NoteService {
return &NoteService{repo: repo}
}
func (s *NoteService) Create(ctx context.Context, in CreateNoteInput) (*NoteOutput, error) {
// uuid.NewString genera UUID v4. La generación de ID es responsabilidad
// de la APP, no del dominio (que no conoce el paquete uuid).
id := uuid.NewString()
n, err := note.New(id, in.Title, in.Content)
if err != nil {
return nil, err
}
if err := s.repo.Save(ctx, n); err != nil {
return nil, fmt.Errorf("application: create failed: %w", err)
}
return toOutput(n), nil
}
func (s *NoteService) GetByID(ctx context.Context, id string) (*NoteOutput, error) {
n, err := s.repo.FindByID(ctx, id)
if err != nil {
return nil, err
}
return toOutput(n), nil
}
func (s *NoteService) List(ctx context.Context) ([]*NoteOutput, error) {
notes, err := s.repo.FindAll(ctx)
if err != nil {
return nil, fmt.Errorf("application: list failed: %w", err)
}
out := make([]*NoteOutput, 0, len(notes))
for _, n := range notes {
out = append(out, toOutput(n))
}
return out, nil
}
func (s *NoteService) Update(ctx context.Context, in UpdateNoteInput) (*NoteOutput, error) {
n, err := s.repo.FindByID(ctx, in.ID)
if err != nil {
return nil, err
}
if err := n.UpdateContent(in.Title, in.Content); err != nil {
return nil, err
}
if err := s.repo.Save(ctx, n); err != nil {
return nil, fmt.Errorf("application: update failed: %w", err)
}
return toOutput(n), nil
}
func (s *NoteService) Delete(ctx context.Context, id string) error {
return s.repo.Delete(ctx, id)
}
func toOutput(n *note.Note) *NoteOutput {
const iso = "2006-01-02T15:04:05Z07:00"
return &NoteOutput{
ID: n.ID(),
Title: n.Title(),
Content: n.Content(),
CreatedAt: n.CreatedAt().Format(iso),
UpdatedAt: n.UpdatedAt().Format(iso),
}
}
Paso 9.2 — internal/application/module.go
package application
import "go.uber.org/fx"
var Module = fx.Options(
fx.Provide(NewNoteService),
)
Fase 10: HTTP con Gin + Fx
Paso 10.1 — Mapeo de errores (internal/infrastructure/http/error_mapper.go)
package http
import (
"errors"
"net/http"
"github.com/tu-usuario/notes-api-pro/internal/domain/note"
)
// mapDomainError traduce errores de dominio a (status, mensaje) HTTP.
// Punto único de traducción: agregar un nuevo error de dominio
// solo requiere tocar este switch.
func mapDomainError(err error) (int, string) {
switch {
case errors.Is(err, note.ErrNotFound):
return http.StatusNotFound, err.Error()
case errors.Is(err, note.ErrEmptyTitle),
errors.Is(err, note.ErrTitleTooLong),
errors.Is(err, note.ErrInvalidID):
return http.StatusUnprocessableEntity, err.Error()
default:
return http.StatusInternalServerError, "internal server error"
}
}
Paso 10.2 — Handler con Gin (internal/infrastructure/http/note_handler.go)
package http
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/tu-usuario/notes-api-pro/internal/application"
)
// NoteHandler: driving adapter con Gin.
type NoteHandler struct {
service *application.NoteService
}
func NewNoteHandler(service *application.NoteService) *NoteHandler {
return &NoteHandler{service: service}
}
// Request DTOs con tags de binding/validación de Gin (go-playground/validator).
// La validación de ENTRADA (formato) vive aquí; la de NEGOCIO, en el dominio.
type createNoteRequest struct {
Title string `json:"title" binding:"required,max=200"`
Content string `json:"content" binding:"max=10000"`
}
type updateNoteRequest struct {
Title string `json:"title" binding:"required,max=200"`
Content string `json:"content" binding:"max=10000"`
}
func (h *NoteHandler) Create(c *gin.Context) {
var req createNoteRequest
// ShouldBindJSON valida automáticamente según las tags. Si falla,
// devuelve el error de validación sin que escribamos un if por campo.
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request", "message": err.Error()})
return
}
out, err := h.service.Create(c.Request.Context(), application.CreateNoteInput{
Title: req.Title,
Content: req.Content,
})
if err != nil {
status, msg := mapDomainError(err)
c.JSON(status, gin.H{"error": http.StatusText(status), "message": msg})
return
}
c.JSON(http.StatusCreated, out)
}
func (h *NoteHandler) Get(c *gin.Context) {
id := c.Param("id") // Gin extrae el path param
out, err := h.service.GetByID(c.Request.Context(), id)
if err != nil {
status, msg := mapDomainError(err)
c.JSON(status, gin.H{"error": http.StatusText(status), "message": msg})
return
}
c.JSON(http.StatusOK, out)
}
func (h *NoteHandler) List(c *gin.Context) {
out, err := h.service.List(c.Request.Context())
if err != nil {
status, msg := mapDomainError(err)
c.JSON(status, gin.H{"error": http.StatusText(status), "message": msg})
return
}
c.JSON(http.StatusOK, out)
}
func (h *NoteHandler) Update(c *gin.Context) {
id := c.Param("id")
var req updateNoteRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request", "message": err.Error()})
return
}
out, err := h.service.Update(c.Request.Context(), application.UpdateNoteInput{
ID: id,
Title: req.Title,
Content: req.Content,
})
if err != nil {
status, msg := mapDomainError(err)
c.JSON(status, gin.H{"error": http.StatusText(status), "message": msg})
return
}
c.JSON(http.StatusOK, out)
}
func (h *NoteHandler) Delete(c *gin.Context) {
id := c.Param("id")
if err := h.service.Delete(c.Request.Context(), id); err != nil {
status, msg := mapDomainError(err)
c.JSON(status, gin.H{"error": http.StatusText(status), "message": msg})
return
}
c.Status(http.StatusNoContent)
}
// RegisterRoutes monta las rutas en el engine de Gin.
// Agrupamos bajo /api/v1 para versionado.
func (h *NoteHandler) RegisterRoutes(engine *gin.Engine) {
v1 := engine.Group("/api/v1")
{
notes := v1.Group("/notes")
{
notes.POST("", h.Create)
notes.GET("", h.List)
notes.GET("/:id", h.Get)
notes.PUT("/:id", h.Update)
notes.DELETE("/:id", h.Delete)
}
}
engine.GET("/health", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"status": "ok"})
})
}
Paso 10.3 — Server + Lifecycle Hook (internal/infrastructure/http/router.go)
Aquí brilla Fx:
fx.Lifecyclegestiona arranque y apagado. El graceful shutdown que en el flujo puro escribimos manualmente, ahora es un hook declarativo.
package http
import (
"context"
"net/http"
"time"
"github.com/gin-gonic/gin"
"github.com/tu-usuario/notes-api-pro/internal/config"
"go.uber.org/fx"
"go.uber.org/zap"
)
// NewGinEngine provee el engine configurado.
func NewGinEngine(cfg *config.Config, log *zap.Logger) *gin.Engine {
gin.SetMode(cfg.Server.Mode) // release | debug | test
engine := gin.New()
// Middleware: recovery (captura panics) + logger estructurado con Zap.
engine.Use(gin.Recovery())
engine.Use(ginZapMiddleware(log))
return engine
}
// ginZapMiddleware: logging estructurado de cada request con Zap.
func ginZapMiddleware(log *zap.Logger) gin.HandlerFunc {
return func(c *gin.Context) {
start := time.Now()
c.Next() // Procesa la request
log.Info("http request",
zap.String("method", c.Request.Method),
zap.String("path", c.Request.URL.Path),
zap.Int("status", c.Writer.Status()),
zap.Duration("latency", time.Since(start)),
)
}
}
// NewHTTPServer crea el *http.Server. Gin es solo el handler.
func NewHTTPServer(cfg *config.Config, engine *gin.Engine) *http.Server {
return &http.Server{
Addr: ":" + cfg.Server.Port,
Handler: engine,
ReadHeaderTimeout: 5 * time.Second,
ReadTimeout: 10 * time.Second,
WriteTimeout: 10 * time.Second,
IdleTimeout: 60 * time.Second,
}
}
// RegisterHooks conecta el ciclo de vida del servidor al de Fx.
// OnStart: levanta el servidor en goroutine.
// OnStop: graceful shutdown automático cuando Fx recibe SIGTERM/SIGINT.
//
// Fx maneja las señales del SO por nosotros: NO necesitamos signal.NotifyContext.
func RegisterHooks(
lc fx.Lifecycle,
srv *http.Server,
handler *NoteHandler,
engine *gin.Engine,
log *zap.Logger,
) {
// Montamos las rutas antes de arrancar.
handler.RegisterRoutes(engine)
lc.Append(fx.Hook{
OnStart: func(ctx context.Context) error {
log.Info("starting http server", zap.String("addr", srv.Addr))
// Goroutine: ListenAndServe bloquea, OnStart NO debe bloquear.
go func() {
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Error("server error", zap.Error(err))
}
}()
return nil
},
OnStop: func(ctx context.Context) error {
log.Info("stopping http server gracefully")
// Fx pasa un context con timeout; drena conexiones activas.
return srv.Shutdown(ctx)
},
})
}
Paso 10.4 — Módulo HTTP (internal/infrastructure/http/module.go)
package http
import "go.uber.org/fx"
var Module = fx.Options(
fx.Provide(
NewGinEngine,
NewHTTPServer,
NewNoteHandler,
),
// fx.Invoke fuerza la ejecución de RegisterHooks al arrancar el grafo.
// Provide registra constructores; Invoke ejecuta efectos secundarios.
fx.Invoke(RegisterHooks),
)
Provide vs Invoke (distinción crítica de Fx):
fx.Provide: registra un constructor en el grafo. Lazy: solo se ejecuta si alguien lo necesita.fx.Invoke: ejecuta una función siempre al arrancar. Es el “trigger” que materializa el grafo (sin un Invoke, nada se construye).
Fase 11: Composition Root — main.go con Fx
Compara con el flujo puro: Allí,
main.gotenía ~80 líneas de wiring manual + graceful shutdown. Aquí, es declarativo: una lista de módulos. Fx resuelve el orden, las dependencias y el ciclo de vida.
Paso 11.1 — cmd/api/main.go
package main
import (
"github.com/tu-usuario/notes-api-pro/internal/application"
"github.com/tu-usuario/notes-api-pro/internal/config"
httpinfra "github.com/tu-usuario/notes-api-pro/internal/infrastructure/http"
"github.com/tu-usuario/notes-api-pro/internal/infrastructure/persistence"
"github.com/tu-usuario/notes-api-pro/internal/platform/logger"
"go.uber.org/fx"
)
func main() {
fx.New(
// El grafo se compone de módulos. Fx resuelve el DAG completo,
// ordena topológicamente y gestiona el ciclo de vida.
// El orden de los módulos NO importa: Fx infiere las dependencias.
logger.Module, // *zap.Logger + fx event logger
config.Module, // *config.Config (Viper)
persistence.Module, // *gorm.DB + note.Repository
application.Module, // *NoteService
httpinfra.Module, // Gin engine + server + hooks
).Run() // Run = Start + bloquear hasta señal + Stop (graceful)
}
fx.New().Run()hace TODO:
- Construye el grafo (topological sort).
- Ejecuta los
OnStarthooks (arranca el servidor).- Bloquea esperando
SIGINT/SIGTERM.- Ejecuta los
OnStophooks (graceful shutdown).Esto es el principio de mínima acción de Hamilton aplicado a infraestructura: el sistema sigue la trayectoria de menor “esfuerzo” entre nacer y morir.
Fase 12: Compilar, Correr con Hot Reload y Probar
Paso 12.1 — Limpiar y verificar
go mod tidy
go build ./...
Paso 12.2 — Lint (calidad de código)
golangci-lint run ./...
Paso 12.3 — Correr con hot reload (desarrollo)
air
Ahora cada vez que guardes un
.go, Air recompila y reinicia en ~1 segundo. Feedback loop de baja latencia — esencial para el flow de desarrollo en Neovim.
Paso 12.4 — O correr directo (sin hot reload)
go run ./cmd/api
# Con config custom:
SERVER_PORT=9000 DATABASE_DSN=mynotes.db SERVER_MODE=debug go run ./cmd/api
Paso 12.5 — Probar el CRUD completo
# CREATE
curl -X POST http://localhost:8080/api/v1/notes \
-H "Content-Type: application/json" \
-d '{"title":"Nota con Fx","content":"Stack profesional"}'
# LIST
curl http://localhost:8080/api/v1/notes
# GET (usa el UUID retornado)
curl http://localhost:8080/api/v1/notes/{uuid}
# UPDATE
curl -X PUT http://localhost:8080/api/v1/notes/{uuid} \
-H "Content-Type: application/json" \
-d '{"title":"Editado","content":"Contenido nuevo"}'
# DELETE
curl -X DELETE http://localhost:8080/api/v1/notes/{uuid}
# Validación de Gin (binding required) → 400
curl -X POST http://localhost:8080/api/v1/notes \
-H "Content-Type: application/json" \
-d '{"content":"sin título"}'
Paso 12.6 — Commit final
git add .
git commit -m "feat: complete notes API with Fx, Gin, GORM and lifecycle management"
Comparativa Directa: Flujo Puro vs Flujo con Stack
| Aspecto | Flujo Puro (stdlib) | Flujo con Stack |
|---|---|---|
| Wiring | Manual en main.go (~80 líneas) | Declarativo: lista de módulos Fx |
| Graceful shutdown | signal.NotifyContext manual | fx.Lifecycle hooks |
| SQL | Queries manuales + scanNote | GORM (Save/First/Find) |
| Migraciones | CREATE TABLE IF NOT EXISTS manual | db.AutoMigrate() |
| IDs | crypto/rand + hex | uuid.NewString() |
| Routing | http.ServeMux (Go 1.22) | Gin con grupos y versionado |
| Validación input | Manual en handler | Struct tags binding:"required" |
| Logging | slog manual | Zap + middleware integrado |
| Config | os.Getenv | Viper (env + files + defaults) |
| Hot reload | No (recompilar manual) | Air (~1s) |
| Curva de aprendizaje | Baja (solo Go) | Media (conocer Fx/Gin/GORM) |
| Líneas totales aprox. | ~600 | ~550 (menos boilerplate técnico) |
| Control fino | Total | Cedido al framework |
El Invariante: Qué NO Cambió
Prueba de la arquitectura hexagonal: Compara la carpeta
internal/domain/note/entre ambos flujos. Es byte por byte idéntica (salvo el module path).
domain/note/note.go ← idéntico
domain/note/repository.go ← idéntico
domain/note/errors.go ← idéntico
Cambiamos el ORM, el router, el DI, el logger, la config — y el núcleo de negocio permaneció inmutable. Esto es la ley de conservación del dominio:
$$\frac{\partial(\text{Dominio})}{\partial(\text{Framework})} = 0$$
Principio de cierre (NieR: Automata): “Las máquinas heredan formas, pero el alma del sistema —su propósito— trasciende el chasis.” Gin, GORM y Fx son chasis intercambiables. El dominio es el alma. Diseña el alma primero; el chasis es negociable hasta el último commit.
¿Profundizo en el siguiente nivel — testing con fxtest (arrancar el grafo en tests de integración), o migrar este mismo grafo Fx a un servidor gRPC reusando los módulos de dominio/aplicación intactos?