Flujo Profesional de Desarrollo: API de Notas en Go con Uber Fx, Gin, GORM y Stack de Industria
Backend

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.

Por Omar Flores
#golang #uber-fx #gin #gorm #dependency-injection #hexagonal-architecture #ddd #rest-api #backend #project-tutorial

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íaPaquetePor qué este y no otro
DI Frameworkgo.uber.org/fxCiclo de vida (lifecycle) + grafo de dependencias automático
HTTP Routergithub.com/gin-gonic/ginPerformance, middleware ecosystem, binding/validación nativa
ORMgorm.io/gorm + gorm.io/driver/sqliteMigraciones automáticas, hooks, menos SQL manual
IDsgithub.com/google/uuidUUID v4/v7 estándar, sin reinventar crypto/rand
Validacióngithub.com/go-playground/validator (vía Gin)Declarativa con struct tags
Logginggo.uber.org/zapEstructurado, alto rendimiento, integra con Fx
Configgithub.com/spf13/viperEnv + archivos + flags unificados
Hot Reloadgithub.com/air-verse/air (devtool)Recompilación automática en desarrollo
Lintinggolangci-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)/bin en tu $PATH. En tu ~/.config/fish/config.fish o ~/.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.go por capa: Cada paquete expone un fx.Module que agrupa sus providers. El main.go solo 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.Logger que 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. Con fx.As(new(note.Repository)), el grafo expone la interfaz. El NoteService pide note.Repository y 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.Lifecycle gestiona 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.go tení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:

  1. Construye el grafo (topological sort).
  2. Ejecuta los OnStart hooks (arranca el servidor).
  3. Bloquea esperando SIGINT/SIGTERM.
  4. Ejecuta los OnStop hooks (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

AspectoFlujo Puro (stdlib)Flujo con Stack
WiringManual en main.go (~80 líneas)Declarativo: lista de módulos Fx
Graceful shutdownsignal.NotifyContext manualfx.Lifecycle hooks
SQLQueries manuales + scanNoteGORM (Save/First/Find)
MigracionesCREATE TABLE IF NOT EXISTS manualdb.AutoMigrate()
IDscrypto/rand + hexuuid.NewString()
Routinghttp.ServeMux (Go 1.22)Gin con grupos y versionado
Validación inputManual en handlerStruct tags binding:"required"
Loggingslog manualZap + middleware integrado
Configos.GetenvViper (env + files + defaults)
Hot reloadNo (recompilar manual)Air (~1s)
Curva de aprendizajeBaja (solo Go)Media (conocer Fx/Gin/GORM)
Líneas totales aprox.~600~550 (menos boilerplate técnico)
Control finoTotalCedido 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?