API de Notas Production-Grade en Go: Postgres, JWT, Argon2id, Docker y Agnosticismo de Persistencia
Backend

API de Notas Production-Grade en Go: Postgres, JWT, Argon2id, Docker y Agnosticismo de Persistencia

Tercera parte de la serie: llevamos la API de notas a producción con Go + Uber Fx + Gin + pgx + Postgres, autenticación JWT, hashing Argon2id, autorización por dueño, migraciones versionadas con golang-migrate y despliegue con Docker multi-stage. El dominio permanece intacto en cada evolución.

Por Omar Flores
#golang #postgresql #pgx #jwt #argon2 #docker #authentication #authorization #hexagonal-architecture #ddd #uber-fx #project-tutorial

API de Notas Production-Grade: Postgres, Auth, Docker y Agnosticismo de Persistencia

Go + Fx + Gin + pgx + JWT + Argon2id + golang-migrate + Docker

Cambio de fase termodinámico: Pasamos de un sistema aislado (proceso único + SQLite empotrado) a un sistema abierto en equilibrio dinámico (servicio sin estado + Postgres externo + identidad criptográfica). La energía libre que ganamos —escalabilidad horizontal, seguridad, portabilidad— exige un coste de orden: fronteras explícitas. El núcleo de este diseño es que ninguna decisión de infraestructura sea irreversible.


Fase 0: Los Tres Invariantes del Diseño

Antes del código, fijamos las leyes de conservación del sistema:

Invariante I — Agnosticismo de Persistencia: El dominio define el puerto; la base de datos es un adaptador desechable. Cambiar Postgres → MySQL → Mongo es escribir un paquete nuevo que satisface la misma interfaz y cambiar una línea en el composition root.

Invariante II — Seguridad por Construcción: La autenticación (¿quién eres?) y la autorización (¿qué puedes tocar?) son ciudadanos de primera clase. Una nota pertenece a un usuario; nadie lee notas ajenas. Los secretos viven en el entorno, jamás en el código.

Invariante III — Reversibilidad de Negocio: Cada capa es separable. Mañana este monolito se parte en microservicios y el dominio no se entera.

El mapa de fronteras

   ┌──────────────────── DRIVING SIDE ────────────────────┐
   │  HTTP (Gin)  →  Middleware Auth  →  Handlers          │
   └───────────────────────┬───────────────────────────────┘

   ┌──────────────── APPLICATION (Use Cases) ──────────────┐
   │   AuthService          NoteService                     │
   │   (Hasher port)        (Repository port + ownership)   │
   │   (TokenIssuer port)                                   │
   └───────────────────────┬───────────────────────────────┘

   ┌──────────────────── DOMAIN (núcleo) ──────────────────┐
   │   User Entity    Note Entity (con OwnerID)             │
   │   Repository Ports    Domain Errors                    │
   └───────────────────────┬───────────────────────────────┘

   ┌──────────────────── DRIVEN SIDE ──────────────────────┐
   │  pgx Repository   Argon2id Hasher   JWT Manager        │
   │  ↑ INTERCAMBIABLES: cada uno satisface un puerto       │
   └────────────────────────────────────────────────────────┘

Fase 1: Inicialización + Stack

Paso 1.1 — Crear módulo

mkdir notes-api-prod && cd notes-api-prod
go mod init github.com/tu-usuario/notes-api-prod

Paso 1.2 — Dependencias core

# DI + logging + HTTP
go get go.uber.org/fx@latest
go get go.uber.org/zap@latest
go get github.com/gin-gonic/gin@latest

# Postgres: pgx es EL driver de la industria (pooling nativo, sin ORM lock-in)
go get github.com/jackc/pgx/v5@latest

# Migraciones versionadas (production-grade, no AutoMigrate)
go get -tags 'postgres' github.com/golang-migrate/migrate/v4@latest

# Identidad + seguridad
go get github.com/google/uuid@latest
go get github.com/golang-jwt/jwt/v5@latest
go get golang.org/x/crypto@latest   # argon2id

# Config
go get github.com/spf13/viper@latest
go get github.com/go-playground/validator/v10@latest

Por qué pgx y no GORM/database/sql: GORM te ata a GORM (la dependencia es el acoplamiento). database/sql es portable pero deja performance sobre la mesa. pgx/v5 con pgxpool da connection pooling de primera clase y APIs tipadas. El agnosticismo de DB no lo da el driver — lo da el puerto del dominio. El driver es libre de ser el mejor para su motor.

Paso 1.3 — DevTools

go install github.com/air-verse/air@latest
go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest
# CLI de migrate para crear/correr migraciones
go install -tags 'postgres' github.com/golang-migrate/migrate/v4/cmd/migrate@latest

Fase 2: Estructura Modular (separable por bounded context)

mkdir -p cmd/api
mkdir -p internal/domain/{note,user}
mkdir -p internal/application/{note,auth}
mkdir -p internal/infrastructure/persistence/postgres
mkdir -p internal/infrastructure/http/{handler,middleware}
mkdir -p internal/infrastructure/security/{hasher,token}
mkdir -p internal/config
mkdir -p internal/platform/logger
mkdir -p migrations
mkdir -p deployments
notes-api-prod/
├── cmd/api/main.go                          # Composition root (Fx)
├── internal/
│   ├── domain/
│   │   ├── note/   note.go · repository.go · errors.go    # OwnerID incluido
│   │   └── user/   user.go · repository.go · errors.go
│   ├── application/
│   │   ├── note/   service.go · dto.go · module.go
│   │   └── auth/   service.go · ports.go · dto.go · module.go
│   ├── infrastructure/
│   │   ├── persistence/postgres/  pool.go · note_repo.go · user_repo.go · module.go
│   │   ├── http/
│   │   │   ├── handler/    note_handler.go · auth_handler.go
│   │   │   ├── middleware/ auth.go · request_id.go · recovery.go
│   │   │   └── server.go · router.go · errors.go · module.go
│   │   └── security/
│   │       ├── hasher/   argon2.go · module.go
│   │       └── token/    jwt.go · module.go
│   ├── config/   config.go · module.go
│   └── platform/logger/  zap.go · module.go
├── migrations/   000001_*.sql · 000002_*.sql
├── deployments/  Dockerfile · docker-compose.yml · .dockerignore
├── .air.toml · .golangci.yml · Makefile · .env.example
└── go.mod

Separación por bounded context: note y user/auth son contextos delimitados. El día que negocio diga “saquemos auth a un servicio aparte”, mueves domain/user + application/auth + sus adaptadores a otro repo y el contexto note solo necesita un cliente para validar tokens. Las costuras ya están cortadas.


Fase 3: DevTools — Makefile como orquestador

Filosofía Estoica del tooling: “Disciplina externa para liberar foco interno.” El Makefile encapsula los comandos repetitivos: tu mente queda libre para el diseño.

cat > Makefile << 'EOF'
.PHONY: run dev build lint migrate-up migrate-down migrate-create docker-up docker-down test

# Carga variables locales para comandos que las necesiten
include .env
export

run:
	go run ./cmd/api

dev:               ## Hot reload
	air

build:
	CGO_ENABLED=0 go build -o ./bin/api ./cmd/api

lint:
	golangci-lint run ./...

migrate-up:        ## Aplica migraciones pendientes
	migrate -path ./migrations -database "$(DATABASE_URL)" up

migrate-down:      ## Revierte la última migración
	migrate -path ./migrations -database "$(DATABASE_URL)" down 1

migrate-create:    ## make migrate-create name=create_x
	migrate create -ext sql -dir ./migrations -seq $(name)

docker-up:
	docker compose -f deployments/docker-compose.yml up --build

docker-down:
	docker compose -f deployments/docker-compose.yml down -v

test:
	go test -race -cover ./...
EOF

.env.example (plantilla; el .env real nunca se commitea):

cat > .env.example << 'EOF'
ENV=development
SERVER_PORT=8080
SERVER_MODE=debug

DATABASE_URL=postgres://notes:notes_secret@localhost:5432/notesdb?sslmode=disable
DATABASE_MAX_CONNS=10
DATABASE_MIN_CONNS=2

# Generar con: openssl rand -base64 48
AUTH_JWT_SECRET=CHANGEME_use_openssl_rand_base64_48
AUTH_ACCESS_TTL=15m
AUTH_REFRESH_TTL=168h
EOF
cp .env.example .env   # editar valores reales en .env

.air.toml y .golangci.yml: idénticos al flujo anterior (omito por brevedad — solo cambia bin = "./tmp/main").

cat > .gitignore << 'EOF'
/bin/
/tmp/
.env
.env.local
*.exe
EOF
git init && git add -A && git commit -m "chore: bootstrap production-grade structure and tooling"

Fase 4: Configuración Escalable + Validada (fail-fast)

Principio: Un servicio que arranca con config inválida es una bomba de relojería. Validamos al inicio y morimos rápido si falta un secreto. “Mejor un fallo ruidoso al nacer que uno silencioso en producción.”

internal/config/config.go:

package config

import (
	"fmt"
	"strings"
	"time"

	"github.com/go-playground/validator/v10"
	"github.com/spf13/viper"
)

// Config es la raíz tipada. Cada sub-struct mapea un dominio de configuración.
// Las tags validate fuerzan invariantes ANTES de que el grafo se construya.
type Config struct {
	Env      string         `mapstructure:"env"      validate:"required,oneof=development staging production"`
	Server   ServerConfig   `mapstructure:"server"   validate:"required"`
	Database DatabaseConfig `mapstructure:"database" validate:"required"`
	Auth     AuthConfig     `mapstructure:"auth"     validate:"required"`
}

type ServerConfig struct {
	Port string `mapstructure:"port" validate:"required"`
	Mode string `mapstructure:"mode" validate:"required,oneof=debug release test"`
}

type DatabaseConfig struct {
	URL      string `mapstructure:"url"       validate:"required,uri"`
	MaxConns int32  `mapstructure:"max_conns" validate:"gte=1"`
	MinConns int32  `mapstructure:"min_conns" validate:"gte=0"`
}

type AuthConfig struct {
	JWTSecret  string        `mapstructure:"jwt_secret"  validate:"required,min=32"` // ≥256 bits
	AccessTTL  time.Duration `mapstructure:"access_ttl"  validate:"required"`
	RefreshTTL time.Duration `mapstructure:"refresh_ttl" validate:"required"`
}

func (c *Config) IsProduction() bool { return c.Env == "production" }

// Load construye y VALIDA la configuración. Precedencia: ENV > defaults.
func Load() (*Config, error) {
	v := viper.New()

	v.SetDefault("env", "development")
	v.SetDefault("server.port", "8080")
	v.SetDefault("server.mode", "release")
	v.SetDefault("database.max_conns", 10)
	v.SetDefault("database.min_conns", 2)
	v.SetDefault("auth.access_ttl", "15m")
	v.SetDefault("auth.refresh_ttl", "168h")

	// SERVER_PORT, DATABASE_URL, AUTH_JWT_SECRET, etc.
	v.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
	v.AutomaticEnv()
	// Viper no auto-descubre claves anidadas sin binding explícito:
	for _, key := range []string{
		"env", "server.port", "server.mode",
		"database.url", "database.max_conns", "database.min_conns",
		"auth.jwt_secret", "auth.access_ttl", "auth.refresh_ttl",
	} {
		_ = v.BindEnv(key)
	}

	var cfg Config
	if err := v.Unmarshal(&cfg); err != nil {
		return nil, fmt.Errorf("config: unmarshal failed: %w", err)
	}

	if err := validator.New().Struct(&cfg); err != nil {
		return nil, fmt.Errorf("config: validation failed: %w", err)
	}
	return &cfg, nil
}

internal/config/module.go:

package config

import "go.uber.org/fx"

var Module = fx.Provide(Load)

Escalabilidad de config: Añadir un nuevo subsistema (ej. Redis, S3) es agregar un struct con sus validate tags. La validación es composicional: el grafo Fx ni arranca si la config es inválida.


Fase 5: Logger (Zap + Fx events)

internal/platform/logger/zap.go:

package logger

import (
	"github.com/tu-usuario/notes-api-prod/internal/config"
	"go.uber.org/zap"
)

// New ajusta el logger al entorno: JSON estructurado en prod,
// human-readable y verboso en desarrollo.
func New(cfg *config.Config) (*zap.Logger, error) {
	if cfg.IsProduction() {
		return zap.NewProduction()
	}
	return zap.NewDevelopment()
}

internal/platform/logger/module.go:

package logger

import (
	"go.uber.org/fx"
	"go.uber.org/fx/fxevent"
	"go.uber.org/zap"
)

var Module = fx.Options(
	fx.Provide(New),
	fx.WithLogger(func(log *zap.Logger) fxevent.Logger {
		return &fxevent.ZapLogger{Logger: log}
	}),
)

Fase 6: Dominio — User + Note con propiedad

El dominio sigue siendo Go puro. Ni pgx, ni JWT, ni Gin lo tocan. La única novedad: Note ahora conoce a su dueño (ownerID), y nace el agregado User.

internal/domain/user/errors.go:

package user

import "errors"

var (
	ErrNotFound        = errors.New("user not found")
	ErrEmailTaken      = errors.New("email already registered")
	ErrInvalidEmail    = errors.New("invalid email")
	ErrWeakPassword    = errors.New("password does not meet requirements")
	ErrInvalidPassword = errors.New("invalid credentials")
)

internal/domain/user/user.go:

package user

import (
	"net/mail"
	"strings"
	"time"
)

// User es el agregado de identidad.
// passwordHash es OPACO al dominio: el dominio no sabe (ni debe saber)
// qué algoritmo lo generó. Esa decisión vive en infraestructura/security.
type User struct {
	id           string
	email        string
	passwordHash string
	createdAt    time.Time
}

// New crea un usuario válido. Recibe el hash YA calculado:
// el hashing es un servicio de infraestructura, no lógica de dominio puro,
// y el dominio no debe importar golang.org/x/crypto. Inversión de responsabilidad.
func New(id, email, passwordHash string) (*User, error) {
	email = strings.ToLower(strings.TrimSpace(email))
	if _, err := mail.ParseAddress(email); err != nil {
		return nil, ErrInvalidEmail
	}
	if passwordHash == "" {
		return nil, ErrWeakPassword
	}
	return &User{
		id: id, email: email, passwordHash: passwordHash,
		createdAt: time.Now().UTC(),
	}, nil
}

func Reconstitute(id, email, passwordHash string, createdAt time.Time) *User {
	return &User{id: id, email: email, passwordHash: passwordHash, createdAt: createdAt}
}

func (u *User) ID() string           { return u.id }
func (u *User) Email() string        { return u.email }
func (u *User) PasswordHash() string { return u.passwordHash }
func (u *User) CreatedAt() time.Time { return u.createdAt }

internal/domain/user/repository.go:

package user

import "context"

type Repository interface {
	Save(ctx context.Context, u *User) error
	FindByEmail(ctx context.Context, email string) (*User, error)
	FindByID(ctx context.Context, id string) (*User, error)
}

internal/domain/note/errors.go (añade ErrForbidden):

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")
	ErrForbidden    = errors.New("access to this note is forbidden") // AuthZ
)

internal/domain/note/note.go (ahora con ownerID y chequeo de propiedad):

package note

import (
	"strings"
	"time"
)

const maxTitleLength = 200

type Note struct {
	id        string
	ownerID   string // FK lógica al User dueño — base de la autorización
	title     string
	content   string
	createdAt time.Time
	updatedAt time.Time
}

func New(id, ownerID, title, content string) (*Note, error) {
	title = strings.TrimSpace(title)
	if title == "" {
		return nil, ErrEmptyTitle
	}
	if len(title) > maxTitleLength {
		return nil, ErrTitleTooLong
	}
	now := time.Now().UTC()
	return &Note{id: id, ownerID: ownerID, title: title, content: content, createdAt: now, updatedAt: now}, nil
}

func Reconstitute(id, ownerID, title, content string, createdAt, updatedAt time.Time) *Note {
	return &Note{id: id, ownerID: ownerID, title: title, content: content, createdAt: createdAt, updatedAt: updatedAt}
}

// IsOwnedBy: la autorización vive en el DOMINIO, no en el handler.
// Regla de negocio: solo el dueño manipula su nota. Inmune a bypass de capas.
func (n *Note) IsOwnedBy(userID string) bool {
	return n.ownerID == userID
}

func (n *Note) Update(title, content string) error {
	title = strings.TrimSpace(title)
	if title == "" {
		return ErrEmptyTitle
	}
	if len(title) > maxTitleLength {
		return ErrTitleTooLong
	}
	n.title, n.content = title, content
	n.updatedAt = time.Now().UTC()
	return nil
}

func (n *Note) ID() string           { return n.id }
func (n *Note) OwnerID() string      { return n.ownerID }
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 }

internal/domain/note/repository.go (las queries se acotan por dueño):

package note

import "context"

type Repository interface {
	Save(ctx context.Context, n *Note) error
	FindByID(ctx context.Context, id string) (*Note, error)
	// FindByOwner: aislamiento de datos a nivel de query (defensa en profundidad).
	FindByOwner(ctx context.Context, ownerID string) ([]*Note, error)
	Delete(ctx context.Context, id string) error
}

Defensa en profundidad: la autorización se aplica en dos capas: la query (FindByOwner solo trae notas del usuario) y el dominio (IsOwnedBy valida antes de mutar/borrar). Aunque un bug deje pasar una query mal filtrada, el dominio bloquea. Capas redundantes, como el blindaje de una nave: una brecha no compromete el casco.


Fase 7: Seguridad — Hashing (Argon2id) y Tokens (JWT)

Paso 7.1 — Puertos de seguridad (definidos donde se consumen)

internal/application/auth/ports.go:

package auth

// Hasher: puerto de hashing. El algoritmo concreto (Argon2id, bcrypt, scrypt)
// es un detalle intercambiable. Si argon2id cae, swap del adaptador, dominio intacto.
type Hasher interface {
	Hash(password string) (string, error)
	Verify(password, encodedHash string) (bool, error)
}

// TokenIssuer: puerto de emisión/validación de tokens.
// Hoy JWT; mañana PASETO o tokens opacos contra Redis. Sin tocar el caso de uso.
type TokenIssuer interface {
	GenerateAccessToken(userID string) (string, error)
	GenerateRefreshToken(userID string) (string, error)
	ValidateAccessToken(token string) (userID string, err error)
}

Paso 7.2 — Argon2id (estándar OWASP para passwords)

internal/infrastructure/security/hasher/argon2.go:

package hasher

import (
	"crypto/rand"
	"crypto/subtle"
	"encoding/base64"
	"errors"
	"fmt"
	"strings"

	"golang.org/x/crypto/argon2"
)

// Parámetros recomendados por OWASP (2024) para Argon2id.
// memory: resistencia a ataques con GPU/ASIC (coste de memoria, no solo CPU).
type params struct {
	memory      uint32 // KiB
	iterations  uint32
	parallelism uint8
	saltLength  uint32
	keyLength   uint32
}

var defaultParams = params{memory: 64 * 1024, iterations: 3, parallelism: 2, saltLength: 16, keyLength: 32}

var ErrInvalidHash = errors.New("hasher: invalid encoded hash")

type Argon2Hasher struct{ p params }

func New() *Argon2Hasher { return &Argon2Hasher{p: defaultParams} }

// Hash genera salt aleatorio y codifica todo en formato PHC estándar:
// $argon2id$v=19$m=...,t=...,p=...$<salt>$<hash>
// Self-describing: el hash lleva sus propios parámetros para verificación futura.
func (h *Argon2Hasher) Hash(password string) (string, error) {
	salt := make([]byte, h.p.saltLength)
	if _, err := rand.Read(salt); err != nil {
		return "", fmt.Errorf("hasher: salt generation: %w", err)
	}
	key := argon2.IDKey([]byte(password), salt, h.p.iterations, h.p.memory, h.p.parallelism, h.p.keyLength)

	return fmt.Sprintf("$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s",
		argon2.Version, h.p.memory, h.p.iterations, h.p.parallelism,
		base64.RawStdEncoding.EncodeToString(salt),
		base64.RawStdEncoding.EncodeToString(key),
	), nil
}

// Verify recalcula el hash con los parámetros embebidos y compara
// en tiempo constante (subtle.ConstantTimeCompare) para evitar timing attacks.
func (h *Argon2Hasher) Verify(password, encoded string) (bool, error) {
	parts := strings.Split(encoded, "$")
	if len(parts) != 6 {
		return false, ErrInvalidHash
	}
	var mem, iter uint32
	var par uint8
	if _, err := fmt.Sscanf(parts[3], "m=%d,t=%d,p=%d", &mem, &iter, &par); err != nil {
		return false, ErrInvalidHash
	}
	salt, err := base64.RawStdEncoding.DecodeString(parts[4])
	if err != nil {
		return false, ErrInvalidHash
	}
	want, err := base64.RawStdEncoding.DecodeString(parts[5])
	if err != nil {
		return false, ErrInvalidHash
	}
	got := argon2.IDKey([]byte(password), salt, iter, mem, par, uint32(len(want)))
	return subtle.ConstantTimeCompare(got, want) == 1, nil
}

internal/infrastructure/security/hasher/module.go:

package hasher

import (
	"github.com/tu-usuario/notes-api-prod/internal/application/auth"
	"go.uber.org/fx"
)

// Proveemos el Argon2Hasher COMO el puerto auth.Hasher (fx.As).
var Module = fx.Provide(
	fx.Annotate(New, fx.As(new(auth.Hasher))),
)

Paso 7.3 — JWT Manager

internal/infrastructure/security/token/jwt.go:

package token

import (
	"errors"
	"fmt"
	"time"

	"github.com/golang-jwt/jwt/v5"
	"github.com/tu-usuario/notes-api-prod/internal/config"
)

var ErrInvalidToken = errors.New("token: invalid or expired")

// claims embebe las RegisteredClaims (exp, iat, sub) + lo nuestro.
type claims struct {
	jwt.RegisteredClaims
	TokenType string `json:"typ"` // "access" | "refresh" — evita usar un refresh como access
}

type JWTManager struct {
	secret     []byte
	accessTTL  time.Duration
	refreshTTL time.Duration
}

func New(cfg *config.Config) *JWTManager {
	return &JWTManager{
		secret:     []byte(cfg.Auth.JWTSecret),
		accessTTL:  cfg.Auth.AccessTTL,
		refreshTTL: cfg.Auth.RefreshTTL,
	}
}

func (m *JWTManager) GenerateAccessToken(userID string) (string, error) {
	return m.generate(userID, "access", m.accessTTL)
}
func (m *JWTManager) GenerateRefreshToken(userID string) (string, error) {
	return m.generate(userID, "refresh", m.refreshTTL)
}

func (m *JWTManager) generate(userID, typ string, ttl time.Duration) (string, error) {
	now := time.Now()
	c := claims{
		RegisteredClaims: jwt.RegisteredClaims{
			Subject:   userID,
			IssuedAt:  jwt.NewNumericDate(now),
			ExpiresAt: jwt.NewNumericDate(now.Add(ttl)),
		},
		TokenType: typ,
	}
	signed, err := jwt.NewWithClaims(jwt.SigningMethodHS256, c).SignedString(m.secret)
	if err != nil {
		return "", fmt.Errorf("token: signing failed: %w", err)
	}
	return signed, nil
}

func (m *JWTManager) ValidateAccessToken(raw string) (string, error) {
	c := &claims{}
	// El keyfunc valida que el algoritmo sea el esperado: defensa contra
	// el ataque "alg=none" y confusión de algoritmos (RS256 vs HS256).
	tok, err := jwt.ParseWithClaims(raw, c, func(t *jwt.Token) (any, error) {
		if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
			return nil, ErrInvalidToken
		}
		return m.secret, nil
	})
	if err != nil || !tok.Valid || c.TokenType != "access" {
		return "", ErrInvalidToken
	}
	return c.Subject, nil
}

internal/infrastructure/security/token/module.go:

package token

import (
	"github.com/tu-usuario/notes-api-prod/internal/application/auth"
	"go.uber.org/fx"
)

var Module = fx.Provide(
	fx.Annotate(New, fx.As(new(auth.TokenIssuer))),
)

Fase 8: Aplicación — AuthService y NoteService

internal/application/auth/dto.go + service.go:

package auth

import (
	"context"
	"errors"
	"fmt"
	"unicode/utf8"

	"github.com/google/uuid"
	"github.com/tu-usuario/notes-api-prod/internal/domain/user"
)

type Credentials struct{ Email, Password string }

type TokenPair struct {
	AccessToken  string `json:"access_token"`
	RefreshToken string `json:"refresh_token"`
}

const minPasswordLen = 8

// Service orquesta identidad. Depende de PUERTOS (Repository, Hasher, TokenIssuer):
// Fx inyecta las implementaciones concretas. Cero acoplamiento a crypto o JWT aquí.
type Service struct {
	users  user.Repository
	hasher Hasher
	tokens TokenIssuer
}

func NewService(users user.Repository, hasher Hasher, tokens TokenIssuer) *Service {
	return &Service{users: users, hasher: hasher, tokens: tokens}
}

func (s *Service) Register(ctx context.Context, c Credentials) (*TokenPair, error) {
	if utf8.RuneCountInString(c.Password) < minPasswordLen {
		return nil, user.ErrWeakPassword
	}
	// Verificación de unicidad ANTES de hashear (ahorra cómputo costoso de argon2).
	if _, err := s.users.FindByEmail(ctx, c.Email); err == nil {
		return nil, user.ErrEmailTaken
	} else if !errors.Is(err, user.ErrNotFound) {
		return nil, fmt.Errorf("auth: register lookup: %w", err)
	}

	hash, err := s.hasher.Hash(c.Password)
	if err != nil {
		return nil, fmt.Errorf("auth: hashing: %w", err)
	}
	u, err := user.New(uuid.NewString(), c.Email, hash)
	if err != nil {
		return nil, err
	}
	if err := s.users.Save(ctx, u); err != nil {
		return nil, fmt.Errorf("auth: persist user: %w", err)
	}
	return s.issueTokens(u.ID())
}

func (s *Service) Login(ctx context.Context, c Credentials) (*TokenPair, error) {
	u, err := s.users.FindByEmail(ctx, c.Email)
	if err != nil {
		if errors.Is(err, user.ErrNotFound) {
			// Mismo error que password incorrecto: NO revelamos si el email existe
			// (previene user enumeration). Mensaje uniforme = menos información al atacante.
			return nil, user.ErrInvalidPassword
		}
		return nil, fmt.Errorf("auth: login lookup: %w", err)
	}
	ok, err := s.hasher.Verify(c.Password, u.PasswordHash())
	if err != nil || !ok {
		return nil, user.ErrInvalidPassword
	}
	return s.issueTokens(u.ID())
}

func (s *Service) issueTokens(userID string) (*TokenPair, error) {
	access, err := s.tokens.GenerateAccessToken(userID)
	if err != nil {
		return nil, fmt.Errorf("auth: access token: %w", err)
	}
	refresh, err := s.tokens.GenerateRefreshToken(userID)
	if err != nil {
		return nil, fmt.Errorf("auth: refresh token: %w", err)
	}
	return &TokenPair{AccessToken: access, RefreshToken: refresh}, nil
}

internal/application/auth/module.go:

package auth

import "go.uber.org/fx"

var Module = fx.Provide(NewService)

internal/application/note/service.go (con autorización por dueño):

package note

import (
	"context"
	"fmt"

	"github.com/google/uuid"
	domain "github.com/tu-usuario/notes-api-prod/internal/domain/note"
)

type CreateInput struct{ OwnerID, Title, Content string }
type UpdateInput struct{ ID, OwnerID, Title, Content string }

type Output struct {
	ID        string `json:"id"`
	Title     string `json:"title"`
	Content   string `json:"content"`
	CreatedAt string `json:"created_at"`
	UpdatedAt string `json:"updated_at"`
}

type Service struct{ repo domain.Repository }

func NewService(repo domain.Repository) *Service { return &Service{repo: repo} }

func (s *Service) Create(ctx context.Context, in CreateInput) (*Output, error) {
	n, err := domain.New(uuid.NewString(), in.OwnerID, in.Title, in.Content)
	if err != nil {
		return nil, err
	}
	if err := s.repo.Save(ctx, n); err != nil {
		return nil, fmt.Errorf("note: create: %w", err)
	}
	return toOutput(n), nil
}

func (s *Service) GetByID(ctx context.Context, id, ownerID string) (*Output, error) {
	n, err := s.repo.FindByID(ctx, id)
	if err != nil {
		return nil, err
	}
	if !n.IsOwnedBy(ownerID) {
		return nil, domain.ErrForbidden
	}
	return toOutput(n), nil
}

func (s *Service) List(ctx context.Context, ownerID string) ([]*Output, error) {
	notes, err := s.repo.FindByOwner(ctx, ownerID) // aislamiento a nivel query
	if err != nil {
		return nil, fmt.Errorf("note: list: %w", err)
	}
	out := make([]*Output, 0, len(notes))
	for _, n := range notes {
		out = append(out, toOutput(n))
	}
	return out, nil
}

func (s *Service) Update(ctx context.Context, in UpdateInput) (*Output, error) {
	n, err := s.repo.FindByID(ctx, in.ID)
	if err != nil {
		return nil, err
	}
	if !n.IsOwnedBy(in.OwnerID) {
		return nil, domain.ErrForbidden
	}
	if err := n.Update(in.Title, in.Content); err != nil {
		return nil, err
	}
	if err := s.repo.Save(ctx, n); err != nil {
		return nil, fmt.Errorf("note: update: %w", err)
	}
	return toOutput(n), nil
}

func (s *Service) Delete(ctx context.Context, id, ownerID string) error {
	n, err := s.repo.FindByID(ctx, id)
	if err != nil {
		return err
	}
	if !n.IsOwnedBy(ownerID) {
		return domain.ErrForbidden
	}
	return s.repo.Delete(ctx, id)
}

func toOutput(n *domain.Note) *Output {
	const iso = "2006-01-02T15:04:05Z07:00"
	return &Output{
		ID: n.ID(), Title: n.Title(), Content: n.Content(),
		CreatedAt: n.CreatedAt().Format(iso), UpdatedAt: n.UpdatedAt().Format(iso),
	}
}

Nota de refactor: en un proyecto real extraería un helper authorize(n, ownerID) error para no repetir el chequeo IsOwnedBy en cada método mutador.

internal/application/note/module.go:

package note

import "go.uber.org/fx"

var Module = fx.Provide(NewService)

Fase 9: Persistencia Postgres con pgxpool (el adaptador desechable)

internal/infrastructure/persistence/postgres/pool.go:

package postgres

import (
	"context"
	"fmt"
	"time"

	"github.com/jackc/pgx/v5/pgxpool"
	"github.com/tu-usuario/notes-api-prod/internal/config"
	"go.uber.org/zap"
)

// NewPool crea el connection pool de pgx. El pooling es CRÍTICO para escalar:
// reutiliza conexiones TCP+TLS (caras de establecer) bajo carga concurrente.
func NewPool(ctx context.Context, cfg *config.Config, log *zap.Logger) (*pgxpool.Pool, error) {
	pcfg, err := pgxpool.ParseConfig(cfg.Database.URL)
	if err != nil {
		return nil, fmt.Errorf("postgres: parse config: %w", err)
	}
	pcfg.MaxConns = cfg.Database.MaxConns
	pcfg.MinConns = cfg.Database.MinConns
	pcfg.MaxConnLifetime = time.Hour     // recicla conexiones viejas
	pcfg.HealthCheckPeriod = time.Minute // detecta conexiones muertas

	pool, err := pgxpool.NewWithConfig(ctx, pcfg)
	if err != nil {
		return nil, fmt.Errorf("postgres: create pool: %w", err)
	}

	pingCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
	defer cancel()
	if err := pool.Ping(pingCtx); err != nil {
		pool.Close()
		return nil, fmt.Errorf("postgres: ping: %w", err)
	}
	log.Info("postgres pool established", zap.Int32("max_conns", cfg.Database.MaxConns))
	return pool, nil
}

internal/infrastructure/persistence/postgres/note_repo.go:

package postgres

import (
	"context"
	"errors"
	"fmt"
	"time"

	"github.com/jackc/pgx/v5"
	"github.com/jackc/pgx/v5/pgxpool"
	domain "github.com/tu-usuario/notes-api-prod/internal/domain/note"
)

var _ domain.Repository = (*NoteRepository)(nil) // compile-time check del puerto

type NoteRepository struct{ pool *pgxpool.Pool }

func NewNoteRepository(pool *pgxpool.Pool) *NoteRepository { return &NoteRepository{pool: pool} }

func (r *NoteRepository) Save(ctx context.Context, n *domain.Note) error {
	// UPSERT con parámetros posicionales ($1...): pgx parametriza SIEMPRE,
	// inmune a SQL injection. JAMÁS concatenar strings en queries.
	const q = `
        INSERT INTO notes (id, owner_id, title, content, created_at, updated_at)
        VALUES ($1, $2, $3, $4, $5, $6)
        ON CONFLICT (id) DO UPDATE
           SET title = EXCLUDED.title, content = EXCLUDED.content, updated_at = EXCLUDED.updated_at`
	_, err := r.pool.Exec(ctx, q,
		n.ID(), n.OwnerID(), n.Title(), n.Content(), n.CreatedAt(), n.UpdatedAt())
	if err != nil {
		return fmt.Errorf("postgres: save note: %w", err)
	}
	return nil
}

func (r *NoteRepository) FindByID(ctx context.Context, id string) (*domain.Note, error) {
	const q = `SELECT id, owner_id, title, content, created_at, updated_at FROM notes WHERE id = $1`
	row := r.pool.QueryRow(ctx, q, id)
	n, err := scanNote(row)
	if errors.Is(err, pgx.ErrNoRows) {
		return nil, domain.ErrNotFound // traducción error técnico → dominio
	}
	if err != nil {
		return nil, fmt.Errorf("postgres: find note: %w", err)
	}
	return n, nil
}

func (r *NoteRepository) FindByOwner(ctx context.Context, ownerID string) ([]*domain.Note, error) {
	const q = `SELECT id, owner_id, title, content, created_at, updated_at
               FROM notes WHERE owner_id = $1 ORDER BY created_at DESC`
	rows, err := r.pool.Query(ctx, q, ownerID)
	if err != nil {
		return nil, fmt.Errorf("postgres: query notes: %w", err)
	}
	defer rows.Close()

	var notes []*domain.Note
	for rows.Next() {
		n, err := scanNote(rows)
		if err != nil {
			return nil, fmt.Errorf("postgres: scan note: %w", err)
		}
		notes = append(notes, n)
	}
	return notes, rows.Err()
}

func (r *NoteRepository) Delete(ctx context.Context, id string) error {
	tag, err := r.pool.Exec(ctx, `DELETE FROM notes WHERE id = $1`, id)
	if err != nil {
		return fmt.Errorf("postgres: delete note: %w", err)
	}
	if tag.RowsAffected() == 0 {
		return domain.ErrNotFound
	}
	return nil
}

// scanner unifica pgx.Row y pgx.Rows.
type scanner interface{ Scan(dest ...any) error }

// scanNote: pgx mapea TIMESTAMPTZ a time.Time nativamente, sin parsing manual.
func scanNote(s scanner) (*domain.Note, error) {
	var id, ownerID, title, content string
	var createdAt, updatedAt time.Time
	if err := s.Scan(&id, &ownerID, &title, &content, &createdAt, &updatedAt); err != nil {
		return nil, err
	}
	return domain.Reconstitute(id, ownerID, title, content, createdAt, updatedAt), nil
}

user_repo.go sigue el mismo patrón (lo omito por simetría: Save, FindByEmail, FindByID, traduciendo pgx.ErrNoRowsuser.ErrNotFound, y 23505 unique_violation → user.ErrEmailTaken).

internal/infrastructure/persistence/postgres/module.go:

package postgres

import (
	noteDomain "github.com/tu-usuario/notes-api-prod/internal/domain/note"
	userDomain "github.com/tu-usuario/notes-api-prod/internal/domain/user"
	"go.uber.org/fx"
)

// Module: TODO el adaptador Postgres en un solo lugar.
// Cambiar de motor = sustituir este import por mysql.Module / mongo.Module.
// Los puertos (note.Repository, user.Repository) son el contrato estable.
var Module = fx.Options(
	fx.Provide(NewPool),
	fx.Provide(fx.Annotate(NewNoteRepository, fx.As(new(noteDomain.Repository)))),
	fx.Provide(fx.Annotate(NewUserRepository, fx.As(new(userDomain.Repository)))),
)

AQUÍ vive el agnosticismo (Invariante I): para migrar a MySQL, creas internal/infrastructure/persistence/mysql/ con un mysql.Module que provee las mismas interfaces. En main.go cambias postgres.Modulemysql.Module. Cero cambios en dominio, aplicación o HTTP. El resto del grafo Fx ni se entera del cambio de motor.


Fase 10: Migraciones Versionadas

make migrate-create name=create_users
make migrate-create name=create_notes

migrations/000001_create_users.up.sql:

CREATE TABLE IF NOT EXISTS users (
    id            TEXT PRIMARY KEY,
    email         TEXT NOT NULL UNIQUE,
    password_hash TEXT NOT NULL,
    created_at    TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_users_email ON users (email);

migrations/000002_create_notes.up.sql:

CREATE TABLE IF NOT EXISTS notes (
    id         TEXT PRIMARY KEY,
    owner_id   TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    title      TEXT NOT NULL,
    content    TEXT NOT NULL DEFAULT '',
    created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Índice por owner: las queries SIEMPRE filtran por dueño (autorización).
CREATE INDEX IF NOT EXISTS idx_notes_owner ON notes (owner_id);

Los .down.sql correspondientes: DROP TABLE ....

Por qué migraciones versionadas y no AutoMigrate: AutoMigrate es seductor pero peligroso en producción —no expresa intención, no revierte, no audita. Las migraciones versionadas son el registro fósil del schema: cada cambio queda datado y es reproducible en cualquier entorno. La entropía del schema se gestiona, no se sufre.


Fase 11: HTTP — Gin, Middleware de Auth, Mapeo de Errores

internal/infrastructure/http/middleware/auth.go:

package middleware

import (
	"net/http"
	"strings"

	"github.com/gin-gonic/gin"
	"github.com/tu-usuario/notes-api-prod/internal/application/auth"
)

const ContextUserID = "userID"

// Auth valida el Bearer token y deposita el userID en el contexto de Gin.
// Es la frontera de AUTENTICACIÓN: handlers protegidos asumen un userID válido.
func Auth(tokens auth.TokenIssuer) gin.HandlerFunc {
	return func(c *gin.Context) {
		header := c.GetHeader("Authorization")
		const prefix = "Bearer "
		if !strings.HasPrefix(header, prefix) {
			c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "missing bearer token"})
			return
		}
		userID, err := tokens.ValidateAccessToken(strings.TrimPrefix(header, prefix))
		if err != nil {
			c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid token"})
			return
		}
		c.Set(ContextUserID, userID) // disponible para handlers downstream
		c.Next()
	}
}

internal/infrastructure/http/errors.go:

package http

import (
	"errors"
	"net/http"

	noteDomain "github.com/tu-usuario/notes-api-prod/internal/domain/note"
	userDomain "github.com/tu-usuario/notes-api-prod/internal/domain/user"
)

// MapError: punto ÚNICO de traducción dominio → HTTP. Un nuevo error
// de negocio solo toca este switch. Nunca se filtran detalles internos en 500.
func MapError(err error) (int, string) {
	switch {
	case errors.Is(err, noteDomain.ErrNotFound), errors.Is(err, userDomain.ErrNotFound):
		return http.StatusNotFound, "resource not found"
	case errors.Is(err, noteDomain.ErrForbidden):
		return http.StatusForbidden, "forbidden"
	case errors.Is(err, userDomain.ErrInvalidPassword):
		return http.StatusUnauthorized, "invalid credentials"
	case errors.Is(err, userDomain.ErrEmailTaken):
		return http.StatusConflict, "email already registered"
	case errors.Is(err, noteDomain.ErrEmptyTitle),
		errors.Is(err, noteDomain.ErrTitleTooLong),
		errors.Is(err, userDomain.ErrInvalidEmail),
		errors.Is(err, userDomain.ErrWeakPassword):
		return http.StatusUnprocessableEntity, err.Error()
	default:
		return http.StatusInternalServerError, "internal server error"
	}
}

internal/infrastructure/http/handler/auth_handler.go:

package handler

import (
	"net/http"

	"github.com/gin-gonic/gin"
	"github.com/tu-usuario/notes-api-prod/internal/application/auth"
	apphttp "github.com/tu-usuario/notes-api-prod/internal/infrastructure/http"
)

type AuthHandler struct{ svc *auth.Service }

func NewAuthHandler(svc *auth.Service) *AuthHandler { return &AuthHandler{svc: svc} }

type credentialsRequest struct {
	Email    string `json:"email"    binding:"required,email"`
	Password string `json:"password" binding:"required,min=8"`
}

func (h *AuthHandler) Register(c *gin.Context) {
	var req credentialsRequest
	if err := c.ShouldBindJSON(&req); err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
		return
	}
	tokens, err := h.svc.Register(c.Request.Context(), auth.Credentials{Email: req.Email, Password: req.Password})
	if err != nil {
		status, msg := apphttp.MapError(err)
		c.JSON(status, gin.H{"error": msg})
		return
	}
	c.JSON(http.StatusCreated, tokens)
}

func (h *AuthHandler) Login(c *gin.Context) {
	var req credentialsRequest
	if err := c.ShouldBindJSON(&req); err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
		return
	}
	tokens, err := h.svc.Login(c.Request.Context(), auth.Credentials{Email: req.Email, Password: req.Password})
	if err != nil {
		status, msg := apphttp.MapError(err)
		c.JSON(status, gin.H{"error": msg})
		return
	}
	c.JSON(http.StatusOK, tokens)
}

internal/infrastructure/http/handler/note_handler.go (extrae userID del contexto inyectado por el middleware):

package handler

import (
	"net/http"

	"github.com/gin-gonic/gin"
	noteApp "github.com/tu-usuario/notes-api-prod/internal/application/note"
	apphttp "github.com/tu-usuario/notes-api-prod/internal/infrastructure/http"
	"github.com/tu-usuario/notes-api-prod/internal/infrastructure/http/middleware"
)

type NoteHandler struct{ svc *noteApp.Service }

func NewNoteHandler(svc *noteApp.Service) *NoteHandler { return &NoteHandler{svc: svc} }

type noteRequest struct {
	Title   string `json:"title"   binding:"required,max=200"`
	Content string `json:"content" binding:"max=10000"`
}

// ownerID lee el userID que el middleware Auth depositó. Garantizado presente
// porque estas rutas SIEMPRE pasan por el middleware.
func ownerID(c *gin.Context) string { return c.GetString(middleware.ContextUserID) }

func (h *NoteHandler) Create(c *gin.Context) {
	var req noteRequest
	if err := c.ShouldBindJSON(&req); err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
		return
	}
	out, err := h.svc.Create(c.Request.Context(), noteApp.CreateInput{
		OwnerID: ownerID(c), Title: req.Title, Content: req.Content,
	})
	if err != nil {
		respondErr(c, err)
		return
	}
	c.JSON(http.StatusCreated, out)
}

func (h *NoteHandler) List(c *gin.Context) {
	out, err := h.svc.List(c.Request.Context(), ownerID(c))
	if err != nil {
		respondErr(c, err)
		return
	}
	c.JSON(http.StatusOK, out)
}

func (h *NoteHandler) Get(c *gin.Context) {
	out, err := h.svc.GetByID(c.Request.Context(), c.Param("id"), ownerID(c))
	if err != nil {
		respondErr(c, err)
		return
	}
	c.JSON(http.StatusOK, out)
}

func (h *NoteHandler) Update(c *gin.Context) {
	var req noteRequest
	if err := c.ShouldBindJSON(&req); err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
		return
	}
	out, err := h.svc.Update(c.Request.Context(), noteApp.UpdateInput{
		ID: c.Param("id"), OwnerID: ownerID(c), Title: req.Title, Content: req.Content,
	})
	if err != nil {
		respondErr(c, err)
		return
	}
	c.JSON(http.StatusOK, out)
}

func (h *NoteHandler) Delete(c *gin.Context) {
	if err := h.svc.Delete(c.Request.Context(), c.Param("id"), ownerID(c)); err != nil {
		respondErr(c, err)
		return
	}
	c.Status(http.StatusNoContent)
}

func respondErr(c *gin.Context, err error) {
	status, msg := apphttp.MapError(err)
	c.JSON(status, gin.H{"error": msg})
}

internal/infrastructure/http/router.go:

package http

import (
	"github.com/gin-gonic/gin"
	"github.com/tu-usuario/notes-api-prod/internal/application/auth"
	"github.com/tu-usuario/notes-api-prod/internal/config"
	"github.com/tu-usuario/notes-api-prod/internal/infrastructure/http/handler"
	"github.com/tu-usuario/notes-api-prod/internal/infrastructure/http/middleware"
)

func NewEngine(
	cfg *config.Config,
	authH *handler.AuthHandler,
	noteH *handler.NoteHandler,
	tokens auth.TokenIssuer,
) *gin.Engine {
	gin.SetMode(cfg.Server.Mode)
	e := gin.New()
	e.Use(gin.Recovery(), middleware.RequestID())

	e.GET("/health", func(c *gin.Context) { c.JSON(200, gin.H{"status": "ok"}) })

	v1 := e.Group("/api/v1")

	// Rutas PÚBLICAS (sin auth)
	v1.POST("/auth/register", authH.Register)
	v1.POST("/auth/login", authH.Login)

	// Rutas PROTEGIDAS: el middleware Auth se aplica a TODO el grupo.
	// Imposible olvidar proteger una ruta nueva de notas: hereda el guard.
	notes := v1.Group("/notes", middleware.Auth(tokens))
	{
		notes.POST("", noteH.Create)
		notes.GET("", noteH.List)
		notes.GET("/:id", noteH.Get)
		notes.PUT("/:id", noteH.Update)
		notes.DELETE("/:id", noteH.Delete)
	}
	return e
}

internal/infrastructure/http/server.go (servidor + lifecycle Fx):

package http

import (
	"context"
	"net/http"
	"time"

	"github.com/gin-gonic/gin"
	"github.com/tu-usuario/notes-api-prod/internal/config"
	"go.uber.org/fx"
	"go.uber.org/zap"
)

func NewServer(cfg *config.Config, e *gin.Engine) *http.Server {
	return &http.Server{
		Addr:              ":" + cfg.Server.Port,
		Handler:           e,
		ReadHeaderTimeout: 5 * time.Second,
		ReadTimeout:       10 * time.Second,
		WriteTimeout:      10 * time.Second,
		IdleTimeout:       60 * time.Second,
	}
}

func RegisterHooks(lc fx.Lifecycle, srv *http.Server, log *zap.Logger) {
	lc.Append(fx.Hook{
		OnStart: func(context.Context) error {
			log.Info("http server starting", zap.String("addr", srv.Addr))
			go func() {
				if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
					log.Error("server crashed", zap.Error(err))
				}
			}()
			return nil
		},
		OnStop: func(ctx context.Context) error {
			log.Info("http server graceful shutdown")
			return srv.Shutdown(ctx) // drena conexiones activas
		},
	})
}

internal/infrastructure/http/module.go:

package http

import (
	"github.com/tu-usuario/notes-api-prod/internal/infrastructure/http/handler"
	"go.uber.org/fx"
)

var Module = fx.Options(
	fx.Provide(
		handler.NewAuthHandler,
		handler.NewNoteHandler,
		NewEngine,
		NewServer,
	),
	fx.Invoke(RegisterHooks),
)

(middleware/request_id.go y recovery.go: middlewares triviales que inyectan un X-Request-ID con uuid.NewString() para trazabilidad distribuida — omitidos por brevedad.)


Fase 12: Composition Root

cmd/api/main.go:

package main

import (
	"context"

	authApp "github.com/tu-usuario/notes-api-prod/internal/application/auth"
	noteApp "github.com/tu-usuario/notes-api-prod/internal/application/note"
	"github.com/tu-usuario/notes-api-prod/internal/config"
	httpInfra "github.com/tu-usuario/notes-api-prod/internal/infrastructure/http"
	"github.com/tu-usuario/notes-api-prod/internal/infrastructure/persistence/postgres"
	"github.com/tu-usuario/notes-api-prod/internal/infrastructure/security/hasher"
	"github.com/tu-usuario/notes-api-prod/internal/infrastructure/security/token"
	"github.com/tu-usuario/notes-api-prod/internal/platform/logger"
	"go.uber.org/fx"
)

func main() {
	fx.New(
		// Provee un context base para constructores que lo requieran (pgxpool).
		fx.Provide(context.Background),

		// Plataforma
		logger.Module,
		config.Module,

		// Persistencia: ⬇️ ÚNICA LÍNEA a cambiar para migrar de motor de DB.
		postgres.Module,

		// Seguridad (puertos Hasher + TokenIssuer)
		hasher.Module,
		token.Module,

		// Aplicación
		authApp.Module,
		noteApp.Module,

		// Entrega HTTP
		httpInfra.Module,
	).Run()
}

El punto de inflexión arquitectónico está en una sola línea. postgres.Module. Reemplázalo por sqlite.Module, mysql.Module o mongo.Module y has cambiado el motor de persistencia del sistema entero sin tocar una sola regla de negocio. Como cambiar el núcleo de fusión de una nave sin alterar su misión.


Fase 13: Docker — Imagen mínima + Compose

deployments/Dockerfile (multi-stage → imagen final ~15MB, sin shell, non-root):

# --- Stage 1: builder ---
FROM golang:1.22-alpine AS builder
WORKDIR /src
# Cache de dependencias: copiamos go.mod primero (capa estable)
COPY go.mod go.sum ./
RUN go mod download
COPY . .
# Binario estático: CGO off → corre en imagen scratch sin libc
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /app/api ./cmd/api

# --- Stage 2: runtime mínimo ---
FROM gcr.io/distroless/static-debian12:nonroot
WORKDIR /app
COPY --from=builder /app/api /app/api
# distroless:nonroot ya corre como usuario sin privilegios (UID 65532)
EXPOSE 8080
ENTRYPOINT ["/app/api"]

deployments/.dockerignore:

.git
tmp/
bin/
*.md
.env
.env.local

deployments/docker-compose.yml:

services:
  db:
    image: postgres:16-alpine
    environment:
      POSTGRES_USER: notes
      POSTGRES_PASSWORD: notes_secret
      POSTGRES_DB: notesdb
    ports: ["5432:5432"]
    volumes: [pgdata:/var/lib/postgresql/data]
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U notes -d notesdb"]
      interval: 5s
      timeout: 3s
      retries: 5

  migrate:
    image: migrate/migrate:latest
    depends_on:
      db: { condition: service_healthy }
    volumes: ["../migrations:/migrations"]
    command:
      - "-path=/migrations"
      - "-database=postgres://notes:notes_secret@db:5432/notesdb?sslmode=disable"
      - "up"

  api:
    build:
      context: ..
      dockerfile: deployments/Dockerfile
    depends_on:
      db: { condition: service_healthy }
      migrate: { condition: service_completed_successfully }
    environment:
      ENV: production
      SERVER_PORT: "8080"
      SERVER_MODE: release
      DATABASE_URL: postgres://notes:notes_secret@db:5432/notesdb?sslmode=disable
      AUTH_JWT_SECRET: ${AUTH_JWT_SECRET} # se inyecta desde el .env del host
    ports: ["8080:8080"]

volumes:
  pgdata:

Orden de arranque garantizado: db (healthcheck) → migrate (corre y termina) → api. El servicio migrate con service_completed_successfully asegura que el schema existe antes de que la API acepte tráfico. Causalidad estricta: ningún efecto antes de su causa.


Fase 14: Ejecutar y Probar el Flujo Completo

Paso 14.1 — Levantar todo

# Genera el secreto y lánzalo (Compose lee AUTH_JWT_SECRET del entorno):
export AUTH_JWT_SECRET=$(openssl rand -base64 48)
make docker-up

Paso 14.2 — Desarrollo local (sin Docker, con hot reload)

docker compose -f deployments/docker-compose.yml up db -d  # solo la DB
make migrate-up                                            # schema
make dev                                                   # air + hot reload

Paso 14.3 — Flujo de auth + CRUD

# 1. Registro → devuelve tokens
TOKENS=$(curl -s -X POST localhost:8080/api/v1/auth/register \
  -H "Content-Type: application/json" \
  -d '{"email":"dev@arch.io","password":"supersecret123"}')
ACCESS=$(echo "$TOKENS" | jq -r .access_token)

# 2. Crear nota (autenticado)
curl -X POST localhost:8080/api/v1/notes \
  -H "Authorization: Bearer $ACCESS" \
  -H "Content-Type: application/json" \
  -d '{"title":"Nota segura","content":"Solo yo la veo"}'

# 3. Listar (solo MIS notas)
curl localhost:8080/api/v1/notes -H "Authorization: Bearer $ACCESS"

# 4. Sin token → 401
curl -i localhost:8080/api/v1/notes

# 5. Token de OTRO usuario sobre mi nota → 403 (autorización por dueño)

Paso 14.4 — Commit

git add -A && git commit -m "feat: production-grade API with postgres, jwt auth, ownership authz and docker"

Tabla: Cómo se Materializa Cada Requisito

RequisitoImplementación concretaPunto de cambio
Agnóstico de DBPuerto Repository + postgres.Module aislado1 línea en main.go
Config escalableViper + structs validados + fail-fastNuevo struct + tags
Seguridad de secretos.env (gitignored) + validación min=32Variables de entorno
AutenticaciónJWT HS256 + middleware Bearertoken.Module (swap a PASETO)
Hashing seguroArgon2id (OWASP) + compare en tiempo constantehasher.Module
AutorizaciónOwnerID + IsOwnedBy en dominio + query por dueñoRegla en dominio
Anti-enumerationMismo error login/email-inexistenteauth.Service.Login
Anti SQL-injectionpgx parametrizado ($1) siempreImposible por construcción
SeparabilidadBounded contexts (note vs user/auth)Mover paquetes a otro repo
Deploy reproducibleDocker multi-stage + compose con healthchecksImagen distroless non-root
Migraciones auditablesgolang-migrate versionadoArchivos .sql datados

El Invariante Demostrado

Compara internal/domain/ entre los tres flujos (SQLite-puro, GORM, Postgres-prod). El núcleo de negocio es esencialmente idéntico: la única evolución fue de negocio (añadir OwnerID y User), nunca de infraestructura. Cambiamos driver, ORM→sin-ORM, motor SQLite→Postgres, añadimos auth y Docker — y el dominio absorbió cero deuda técnica de esos cambios.

$$\frac{\partial(\text{Dominio})}{\partial(\text{Infra})} = 0 \qquad \frac{\partial(\text{Dominio})}{\partial(\text{Negocio})} \neq 0$$

Cierre (Bushido): “El maestro no defiende su técnica contra el cambio del mundo; la diseña para que el cambio del mundo no la alcance.” La infraestructura es ropa que se cambia con la estación. El dominio es el cuerpo. Has vestido el mismo cuerpo con tres armaduras distintas, y bajo cada una late idéntico el propósito del sistema.


¿Avanzamos al siguiente eslabón — refresh token rotation con revocación (Redis como blocklist), RBAC (roles más allá de ownership), o exponer este mismo grafo Fx sobre gRPC reutilizando los módulos de dominio y aplicación intactos para tu cliente Flutter?