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.
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/sqles portable pero deja performance sobre la mesa.pgx/v5conpgxpoolda 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:
noteyuser/authson contextos delimitados. El día que negocio diga “saquemos auth a un servicio aparte”, muevesdomain/user+application/auth+ sus adaptadores a otro repo y el contextonotesolo 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
validatetags. 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:
Noteahora conoce a su dueño (ownerID), y nace el agregadoUser.
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 (
FindByOwnersolo trae notas del usuario) y el dominio (IsOwnedByvalida 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) errorpara no repetir el chequeoIsOwnedByen 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.ErrNoRows → user.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 unmysql.Moduleque provee las mismas interfaces. Enmain.gocambiaspostgres.Module→mysql.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 porsqlite.Module,mysql.Moduleomongo.Moduley 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 serviciomigrateconservice_completed_successfullyasegura 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
| Requisito | Implementación concreta | Punto de cambio |
|---|---|---|
| Agnóstico de DB | Puerto Repository + postgres.Module aislado | 1 línea en main.go |
| Config escalable | Viper + structs validados + fail-fast | Nuevo struct + tags |
| Seguridad de secretos | .env (gitignored) + validación min=32 | Variables de entorno |
| Autenticación | JWT HS256 + middleware Bearer | token.Module (swap a PASETO) |
| Hashing seguro | Argon2id (OWASP) + compare en tiempo constante | hasher.Module |
| Autorización | OwnerID + IsOwnedBy en dominio + query por dueño | Regla en dominio |
| Anti-enumeration | Mismo error login/email-inexistente | auth.Service.Login |
| Anti SQL-injection | pgx parametrizado ($1) siempre | Imposible por construcción |
| Separabilidad | Bounded contexts (note vs user/auth) | Mover paquetes a otro repo |
| Deploy reproducible | Docker multi-stage + compose con healthchecks | Imagen distroless non-root |
| Migraciones auditables | golang-migrate versionado | Archivos .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ñadirOwnerIDyUser), 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?