Go 1.25 API en Producción: DDD, TDD, BDD, PostgreSQL, Redis — Setup Completo del Proyecto
Construye una API REST lista para producción con Go 1.25 desde cero. Arquitectura DDD completa, testing TDD/BDD, PostgreSQL, Redis caching, Chi router y cada archivo explicado.
Por Qué Otra Guía de API en Go
Cada tutorial de Go te enseña lo mismo: un main.go plano con handlers que llaman a la base de datos directamente. Funciona para demos. Falla en producción.
Piensa en construir software como construir un hospital. Nunca pondrías la farmacia dentro del quirófano. Nunca enrutarías pacientes por la cocina. Cada departamento tiene un límite claro, una responsabilidad clara, y una forma clara de comunicarse con otros departamentos.
El software en producción funciona igual. La lógica de dominio no sabe de HTTP. La capa de base de datos no sabe de reglas de negocio. La capa de cache no sabe de ninguna. Cada capa tiene un límite, un contrato, y una razón de existir.
Esta guía construye una API completa de gestión de facturas desde el primer go mod init hasta un binario desplegable. Cada archivo se muestra. Cada decisión se explica. Cada paquete se elige por una razón. Sin magia. Sin atajos. Sin “se deja como ejercicio para el lector.”
Terminarás con un proyecto que tiene:
- Diseño Dirigido por Dominio con contextos acotados claros
- Desarrollo Dirigido por Tests con table-driven tests
- Desarrollo Dirigido por Comportamiento con godog y Gherkin
- PostgreSQL con migraciones y connection pooling
- Redis para caching e invalidación por patrones
- Chi router con middleware estructurado
- Logging estructurado con slog
- Gestión de configuración con variables de entorno
- Docker y Docker Compose para desarrollo local
- Un Makefile que une todo
Parte 1: Estructura del Proyecto — Cada Archivo Tiene un Hogar
Antes de escribir una sola línea de código, diseña la estructura. Esta es la decisión que determina si el proyecto escala o colapsa.
invoice-api/
├── cmd/
│ └── api/
│ └── main.go # Punto de entrada — conecta todo
├── internal/
│ ├── domain/
│ │ ├── invoice.go # Entidad + value objects
│ │ ├── invoice_status.go # Enum de status + transiciones
│ │ ├── invoice_repository.go # Interfaz del repositorio (puerto)
│ │ ├── invoice_cache.go # Interfaz del cache (puerto)
│ │ ├── invoice_service.go # Servicio de dominio (reglas de negocio)
│ │ └── errors.go # Errores específicos del dominio
│ ├── application/
│ │ ├── create_invoice.go # Caso de uso: crear
│ │ ├── get_invoice.go # Caso de uso: obtener por ID
│ │ ├── list_invoices.go # Caso de uso: listar con filtros
│ │ ├── update_status.go # Caso de uso: transiciones de status
│ │ └── dto.go # Objetos de transferencia de datos
│ ├── infrastructure/
│ │ ├── postgres/
│ │ │ ├── connection.go # Configuración del pool de conexiones
│ │ │ ├── invoice_repo.go # Implementación repositorio PostgreSQL
│ │ │ └── migrations.go # Migraciones del schema
│ │ ├── redis/
│ │ │ ├── connection.go # Configuración del cliente Redis
│ │ │ └── invoice_cache.go # Implementación cache Redis
│ │ └── config/
│ │ └── config.go # Carga de variables de entorno
│ └── interfaces/
│ └── http/
│ ├── router.go # Router Chi + middleware
│ ├── invoice_handler.go # Handlers HTTP
│ ├── middleware.go # Auth, logging, recovery
│ └── response.go # Respuestas JSON estandarizadas
├── test/
│ ├── integration/
│ │ └── invoice_test.go # Tests de integración completos
│ └── bdd/
│ ├── features/
│ │ └── invoice.feature # Escenarios Gherkin
│ └── invoice_steps_test.go # Definiciones de pasos
├── migrations/
│ ├── 001_create_invoices.up.sql
│ └── 001_create_invoices.down.sql
├── docker-compose.yml
├── Dockerfile
├── Makefile
├── go.mod
└── .env.example
Cada directorio existe por una razón:
- cmd/ contiene el punto de entrada. Nada más. Conecta dependencias e inicia el servidor.
- internal/domain/ es el núcleo. Tiene cero imports de paquetes externos. Sin base de datos. Sin HTTP. Sin framework. Go puro con lógica de negocio pura.
- internal/application/ orquesta casos de uso. Llama al dominio y coordina infraestructura a través de interfaces.
- internal/infrastructure/ implementa las interfaces definidas por el dominio. PostgreSQL, Redis, configuración. Aquí es donde viven las dependencias externas.
- internal/interfaces/http/ traduce HTTP a comandos de aplicación y viceversa. Conoce Chi y JSON pero nada de PostgreSQL.
- test/ contiene tests de integración y features BDD. Los tests unitarios viven junto al código que testean.
Parte 2: Paquetes — Qué Instalar y Por Qué
Cada dependencia debe justificar su existencia. Aquí está el go.mod completo con la razón de cada paquete.
// go.mod
module github.com/yourorg/invoice-api
go 1.25
require (
// HTTP Router — ligero, idiomático, compatible con stdlib
github.com/go-chi/chi/v5 v5.2.1
// Driver PostgreSQL — Go puro, probado en producción
github.com/jackc/pgx/v5 v5.7.4
// Cliente Redis — full-featured, connection pooling integrado
github.com/redis/go-redis/v9 v9.7.3
// Generación de UUID — cumple RFC 4122
github.com/google/uuid v1.6.0
// Variables de entorno — carga de config sin dependencias
github.com/caarlos0/env/v11 v11.3.1
// Migraciones de base de datos — basadas en SQL, sin ORM
github.com/golang-migrate/migrate/v4 v4.18.2
// Validación — basada en struct tags, extensible
github.com/go-playground/validator/v10 v10.24.0
// Framework de testing BDD
github.com/cucumber/godog v0.15.0
// Aserciones de test — fallos de test legibles
github.com/stretchr/testify v1.10.0
)
Por qué estos paquetes específicamente:
- chi sobre gin/fiber: Chi es el único router que envuelve
net/httpsin reemplazarlo. Tus handlers sonhttp.HandlerFuncestándar. Sin lock-in de framework. - pgx sobre database/sql + lib/pq: pgx es más rápido, soporta tipos nativos de PostgreSQL (JSONB, arrays, UUID), y tiene connection pooling integrado vía pgxpool.
- go-redis sobre redigo: Soporte nativo de context, connection pooling, pipelining, y scripting Lua integrado.
- google/uuid sobre otras librerías de UUID: Mantenido por Google, cumple RFC, sin dependencias externas.
- caarlos0/env sobre viper: Viper son 15,000 líneas para lo que env hace en 500. Parsea struct tags de variables de entorno. Nada más.
- golang-migrate sobre goose/atlas: Solo archivos SQL. Sin código Go en migraciones. Tu DBA puede leerlas y revisarlas.
- go-playground/validator sobre validación custom: Probado en batalla, basado en struct tags, soporta validadores custom.
- godog para BDD: La implementación oficial de Cucumber para Go. Parsing Gherkin, definiciones de pasos, hooks.
- testify para aserciones:
assert.Equal(t, expected, actual)es más legible queif got != want { t.Errorf(...) }en table-driven tests.
Parte 3: La Capa de Dominio — Lógica de Negocio Sin Dependencias
El dominio es la capa más importante. Contiene cero imports de paquetes externos. Sin driver de base de datos. Sin librería HTTP. Sin framework. Solo la librería estándar de Go y tus reglas de negocio.
La Entidad Invoice
Una factura no es una fila de base de datos. Es un concepto de negocio con reglas sobre cómo puede ser creada, modificada, y transicionada a través de estados.
// internal/domain/invoice.go
package domain
import (
"time"
"github.com/google/uuid"
)
// Invoice representa un documento financiero emitido a un cliente.
// Todas las reglas de negocio sobre una factura se aplican aquí.
type Invoice struct {
ID uuid.UUID
Number string
ClientName string
ClientEmail string
Items []LineItem
Status InvoiceStatus
Currency string
Notes string
IssuedAt time.Time
DueAt time.Time
PaidAt *time.Time
CreatedAt time.Time
UpdatedAt time.Time
}
// LineItem representa una línea individual en una factura.
type LineItem struct {
Description string
Quantity int
UnitPrice int64 // Centavos para evitar punto flotante
TaxRate int // Puntos base (ej., 1600 = 16%)
}
// TotalCents retorna el total del line item en centavos.
func (li LineItem) TotalCents() int64 {
subtotal := int64(li.Quantity) * li.UnitPrice
tax := subtotal * int64(li.TaxRate) / 10000
return subtotal + tax
}
// NewInvoice crea una factura validada.
// El dominio aplica sus propias reglas en el punto de creación.
func NewInvoice(
number, clientName, clientEmail, currency, notes string,
items []LineItem,
dueAt time.Time,
) (*Invoice, error) {
if number == "" {
return nil, ErrInvoiceNumberRequired
}
if clientName == "" {
return nil, ErrClientNameRequired
}
if clientEmail == "" {
return nil, ErrClientEmailRequired
}
if len(items) == 0 {
return nil, ErrItemsRequired
}
if dueAt.Before(time.Now()) {
return nil, ErrDueDateInPast
}
for _, item := range items {
if item.Quantity <= 0 {
return nil, ErrInvalidQuantity
}
if item.UnitPrice <= 0 {
return nil, ErrInvalidPrice
}
}
now := time.Now()
return &Invoice{
ID: uuid.New(),
Number: number,
ClientName: clientName,
ClientEmail: clientEmail,
Items: items,
Status: StatusDraft,
Currency: currency,
Notes: notes,
IssuedAt: now,
DueAt: dueAt,
CreatedAt: now,
UpdatedAt: now,
}, nil
}
// TotalCents retorna el total de todos los line items en centavos.
func (inv *Invoice) TotalCents() int64 {
var total int64
for _, item := range inv.Items {
total += item.TotalCents()
}
return total
}
// SubtotalCents retorna el subtotal antes de impuestos.
func (inv *Invoice) SubtotalCents() int64 {
var subtotal int64
for _, item := range inv.Items {
subtotal += int64(item.Quantity) * item.UnitPrice
}
return subtotal
}
// TaxCents retorna el monto total de impuestos.
func (inv *Invoice) TaxCents() int64 {
return inv.TotalCents() - inv.SubtotalCents()
}
// IsOverdue retorna true si la factura está vencida y no pagada.
func (inv *Invoice) IsOverdue() bool {
return inv.Status != StatusPaid && time.Now().After(inv.DueAt)
}
Nota dos decisiones de diseño críticas. Primero, el dinero se almacena en centavos como int64, nunca como float64. La aritmética de punto flotante causa errores de redondeo en cálculos financieros. Una factura de $100.10 almacenada como float podría convertirse en $100.09999999. En centavos, siempre es 10010. Segundo, las tasas de impuesto usan puntos base (centésimas de porcentaje). Una tasa del 16% se almacena como 1600. Esto da precisión sin matemáticas de punto flotante.
Transiciones de Estado
Una factura no puede ir de “pagada” de vuelta a “borrador.” Las transiciones de estado son reglas de negocio, no restricciones de base de datos.
// internal/domain/invoice_status.go
package domain
// InvoiceStatus representa el estado del ciclo de vida de una factura.
type InvoiceStatus string
const (
StatusDraft InvoiceStatus = "draft"
StatusSent InvoiceStatus = "sent"
StatusViewed InvoiceStatus = "viewed"
StatusPaid InvoiceStatus = "paid"
StatusOverdue InvoiceStatus = "overdue"
StatusCancelled InvoiceStatus = "cancelled"
)
// validTransitions define qué cambios de estado son permitidos.
var validTransitions = map[InvoiceStatus]map[InvoiceStatus]bool{
StatusDraft: {StatusSent: true, StatusCancelled: true},
StatusSent: {StatusViewed: true, StatusPaid: true, StatusOverdue: true, StatusCancelled: true},
StatusViewed: {StatusPaid: true, StatusOverdue: true, StatusCancelled: true},
StatusOverdue: {StatusPaid: true, StatusCancelled: true},
}
// TransitionTo cambia el estado de la factura si la transición es válida.
func (inv *Invoice) TransitionTo(next InvoiceStatus) error {
allowed, exists := validTransitions[inv.Status]
if !exists {
return ErrStatusTransitionForbidden
}
if !allowed[next] {
return ErrStatusTransitionForbidden
}
inv.Status = next
inv.UpdatedAt = time.Now()
if next == StatusPaid {
now := time.Now()
inv.PaidAt = &now
}
return nil
}
Esta es la clase de lógica que pertenece al dominio, no en un handler HTTP o un trigger de base de datos. Cuando un desarrollador junior intenta marcar una factura cancelada como pagada, el dominio la rechaza antes de que cualquier consulta de base de datos se ejecute.
Puertos — Las Interfaces que el Dominio Necesita
El dominio define lo que necesita del mundo exterior a través de interfaces. No sabe ni le importa quién las implementa.
// internal/domain/invoice_repository.go
package domain
import (
"context"
"github.com/google/uuid"
)
// InvoiceRepository define el contrato para persistencia de facturas.
type InvoiceRepository interface {
Save(ctx context.Context, invoice *Invoice) error
FindByID(ctx context.Context, id uuid.UUID) (*Invoice, error)
FindByNumber(ctx context.Context, number string) (*Invoice, error)
FindAll(ctx context.Context, filter InvoiceFilter) ([]*Invoice, int, error)
Update(ctx context.Context, invoice *Invoice) error
Delete(ctx context.Context, id uuid.UUID) error
}
// InvoiceFilter representa parámetros de consulta para listar facturas.
type InvoiceFilter struct {
Status *InvoiceStatus
ClientName *string
FromDate *time.Time
ToDate *time.Time
Page int
PageSize int
}
// internal/domain/invoice_cache.go
package domain
import (
"context"
"time"
"github.com/google/uuid"
)
// InvoiceCache define el contrato para caching de facturas.
type InvoiceCache interface {
Get(ctx context.Context, id uuid.UUID) (*Invoice, error)
Set(ctx context.Context, invoice *Invoice, ttl time.Duration) error
Delete(ctx context.Context, id uuid.UUID) error
DeleteByPattern(ctx context.Context, pattern string) error
}
// internal/domain/errors.go
package domain
import "errors"
var (
ErrInvoiceNumberRequired = errors.New("invoice number is required")
ErrClientNameRequired = errors.New("client name is required")
ErrClientEmailRequired = errors.New("client email is required")
ErrItemsRequired = errors.New("at least one line item is required")
ErrDueDateInPast = errors.New("due date cannot be in the past")
ErrInvalidQuantity = errors.New("quantity must be positive")
ErrInvalidPrice = errors.New("unit price must be positive")
ErrInvoiceNotFound = errors.New("invoice not found")
ErrInvoiceAlreadyExists = errors.New("invoice with this number already exists")
ErrStatusTransitionForbidden = errors.New("this status transition is not allowed")
)
La capa de dominio está completa. Tiene:
- Una entidad con validación y métodos de negocio
- Una máquina de estados para transiciones de status
- Interfaces (puertos) para persistencia y caching
- Errores tipados para cada caso de fallo
Cero dependencias externas. Cero código de framework. Lógica de negocio pura.
Parte 4: La Capa de Aplicación — Casos de Uso
La capa de aplicación orquesta el dominio. Cada archivo representa un caso de uso. Cada caso de uso hace una cosa.
Crear Factura
// internal/application/create_invoice.go
package application
import (
"context"
"fmt"
"log/slog"
"github.com/yourorg/invoice-api/internal/domain"
)
// CreateInvoiceCommand representa la entrada para crear una factura.
type CreateInvoiceCommand struct {
Number string
ClientName string
ClientEmail string
Currency string
Notes string
Items []LineItemDTO
DueAt string // Fecha ISO 8601
}
// CreateInvoiceHandler maneja el caso de uso de creación de factura.
type CreateInvoiceHandler struct {
repo domain.InvoiceRepository
cache domain.InvoiceCache
logger *slog.Logger
}
func NewCreateInvoiceHandler(
repo domain.InvoiceRepository,
cache domain.InvoiceCache,
logger *slog.Logger,
) *CreateInvoiceHandler {
return &CreateInvoiceHandler{
repo: repo,
cache: cache,
logger: logger,
}
}
func (h *CreateInvoiceHandler) Handle(ctx context.Context, cmd CreateInvoiceCommand) (*domain.Invoice, error) {
// Verifica número de factura duplicado
existing, _ := h.repo.FindByNumber(ctx, cmd.Number)
if existing != nil {
return nil, domain.ErrInvoiceAlreadyExists
}
// Parsea la fecha de vencimiento
dueAt, err := parseDate(cmd.DueAt)
if err != nil {
return nil, fmt.Errorf("invalid due date: %w", err)
}
// Convierte DTOs a line items del dominio
items := make([]domain.LineItem, len(cmd.Items))
for i, dto := range cmd.Items {
items[i] = domain.LineItem{
Description: dto.Description,
Quantity: dto.Quantity,
UnitPrice: dto.UnitPriceCents,
TaxRate: dto.TaxRateBasisPoints,
}
}
// Crea la factura a través del dominio (validación ocurre aquí)
invoice, err := domain.NewInvoice(
cmd.Number, cmd.ClientName, cmd.ClientEmail,
cmd.Currency, cmd.Notes, items, dueAt,
)
if err != nil {
return nil, err
}
// Persiste
if err := h.repo.Save(ctx, invoice); err != nil {
return nil, fmt.Errorf("saving invoice: %w", err)
}
// Invalida cache de listas
_ = h.cache.DeleteByPattern(ctx, "invoices:list:*")
h.logger.Info("invoice created",
slog.String("id", invoice.ID.String()),
slog.String("number", invoice.Number),
slog.Int64("total_cents", invoice.TotalCents()),
)
return invoice, nil
}
Obtener Factura (con Cache)
El caso de uso get muestra el patrón cache-aside: verifica cache primero, cae al base de datos, pobla cache en miss.
// internal/application/get_invoice.go
package application
import (
"context"
"fmt"
"log/slog"
"time"
"github.com/google/uuid"
"github.com/yourorg/invoice-api/internal/domain"
)
type GetInvoiceHandler struct {
repo domain.InvoiceRepository
cache domain.InvoiceCache
logger *slog.Logger
}
func NewGetInvoiceHandler(
repo domain.InvoiceRepository,
cache domain.InvoiceCache,
logger *slog.Logger,
) *GetInvoiceHandler {
return &GetInvoiceHandler{repo: repo, cache: cache, logger: logger}
}
func (h *GetInvoiceHandler) Handle(ctx context.Context, id uuid.UUID) (*domain.Invoice, error) {
// Verifica cache primero
cached, err := h.cache.Get(ctx, id)
if err == nil && cached != nil {
h.logger.Debug("cache hit", slog.String("id", id.String()))
return cached, nil
}
// Cache miss — busca en base de datos
invoice, err := h.repo.FindByID(ctx, id)
if err != nil {
return nil, fmt.Errorf("finding invoice: %w", err)
}
// Pobla cache para la siguiente solicitud (TTL 5 minutos)
_ = h.cache.Set(ctx, invoice, 5*time.Minute)
h.logger.Debug("cache miss, fetched from db", slog.String("id", id.String()))
return invoice, nil
}
Actualizar Status
// internal/application/update_status.go
package application
import (
"context"
"fmt"
"log/slog"
"github.com/google/uuid"
"github.com/yourorg/invoice-api/internal/domain"
)
type UpdateStatusCommand struct {
InvoiceID uuid.UUID
NewStatus domain.InvoiceStatus
}
type UpdateStatusHandler struct {
repo domain.InvoiceRepository
cache domain.InvoiceCache
logger *slog.Logger
}
func NewUpdateStatusHandler(
repo domain.InvoiceRepository,
cache domain.InvoiceCache,
logger *slog.Logger,
) *UpdateStatusHandler {
return &UpdateStatusHandler{repo: repo, cache: cache, logger: logger}
}
func (h *UpdateStatusHandler) Handle(ctx context.Context, cmd UpdateStatusCommand) (*domain.Invoice, error) {
invoice, err := h.repo.FindByID(ctx, cmd.InvoiceID)
if err != nil {
return nil, fmt.Errorf("finding invoice: %w", err)
}
// El dominio aplica transiciones válidas
if err := invoice.TransitionTo(cmd.NewStatus); err != nil {
return nil, err
}
if err := h.repo.Update(ctx, invoice); err != nil {
return nil, fmt.Errorf("updating invoice: %w", err)
}
// Invalida tanto la entrada específica de cache como caches de listas
_ = h.cache.Delete(ctx, cmd.InvoiceID)
_ = h.cache.DeleteByPattern(ctx, "invoices:list:*")
h.logger.Info("invoice status updated",
slog.String("id", cmd.InvoiceID.String()),
slog.String("new_status", string(cmd.NewStatus)),
)
return invoice, nil
}
Parte 5: Infraestructura — PostgreSQL
Pool de Conexiones
// internal/infrastructure/postgres/connection.go
package postgres
import (
"context"
"fmt"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
func NewPool(ctx context.Context, dsn string) (*pgxpool.Pool, error) {
config, err := pgxpool.ParseConfig(dsn)
if err != nil {
return nil, fmt.Errorf("parsing dsn: %w", err)
}
config.MaxConns = 25
config.MinConns = 5
config.MaxConnLifetime = 1 * time.Hour
config.MaxConnIdleTime = 30 * time.Minute
config.HealthCheckPeriod = 1 * time.Minute
pool, err := pgxpool.NewWithConfig(ctx, config)
if err != nil {
return nil, fmt.Errorf("creating pool: %w", err)
}
if err := pool.Ping(ctx); err != nil {
return nil, fmt.Errorf("pinging database: %w", err)
}
return pool, nil
}
Migraciones
-- migrations/001_create_invoices.up.sql
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
CREATE TABLE invoices (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
number VARCHAR(50) NOT NULL UNIQUE,
client_name VARCHAR(255) NOT NULL,
client_email VARCHAR(255) NOT NULL,
items JSONB NOT NULL DEFAULT '[]',
status VARCHAR(20) NOT NULL DEFAULT 'draft',
currency VARCHAR(3) NOT NULL DEFAULT 'USD',
notes TEXT NOT NULL DEFAULT '',
subtotal_cents BIGINT NOT NULL DEFAULT 0,
tax_cents BIGINT NOT NULL DEFAULT 0,
total_cents BIGINT NOT NULL DEFAULT 0,
issued_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
due_at TIMESTAMP WITH TIME ZONE NOT NULL,
paid_at TIMESTAMP WITH TIME ZONE,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_invoices_status ON invoices(status);
CREATE INDEX idx_invoices_client_name ON invoices(client_name);
CREATE INDEX idx_invoices_due_at ON invoices(due_at);
CREATE INDEX idx_invoices_created_at ON invoices(created_at DESC);
CREATE INDEX idx_invoices_number ON invoices(number);
CREATE INDEX idx_invoices_status_created ON invoices(status, created_at DESC);
Los line items se almacenan como JSONB. Esta es una decisión deliberada. Los line items pertenecen a la factura — son value objects, no entidades independientes. Almacenarlos como JSONB evita un JOIN por cada consulta de factura, simplifica el schema, y mantiene el camino de lectura rápido.
Parte 6: Infraestructura — Redis
// internal/infrastructure/redis/invoice_cache.go
package redis
import (
"context"
"encoding/json"
"fmt"
"time"
"github.com/google/uuid"
goredis "github.com/redis/go-redis/v9"
"github.com/yourorg/invoice-api/internal/domain"
)
type InvoiceCache struct {
client *goredis.Client
}
func NewInvoiceCache(client *goredis.Client) *InvoiceCache {
return &InvoiceCache{client: client}
}
func (c *InvoiceCache) Get(ctx context.Context, id uuid.UUID) (*domain.Invoice, error) {
key := fmt.Sprintf("invoice:%s", id.String())
data, err := c.client.Get(ctx, key).Bytes()
if err == goredis.Nil {
return nil, nil // Cache miss, no es un error
}
if err != nil {
return nil, err
}
var invoice domain.Invoice
if err := json.Unmarshal(data, &invoice); err != nil {
return nil, err
}
return &invoice, nil
}
func (c *InvoiceCache) Set(ctx context.Context, invoice *domain.Invoice, ttl time.Duration) error {
key := fmt.Sprintf("invoice:%s", invoice.ID.String())
data, err := json.Marshal(invoice)
if err != nil {
return err
}
return c.client.Set(ctx, key, data, ttl).Err()
}
func (c *InvoiceCache) Delete(ctx context.Context, id uuid.UUID) error {
key := fmt.Sprintf("invoice:%s", id.String())
return c.client.Del(ctx, key).Err()
}
func (c *InvoiceCache) DeleteByPattern(ctx context.Context, pattern string) error {
iter := c.client.Scan(ctx, 0, pattern, 100).Iterator()
var keys []string
for iter.Next(ctx) {
keys = append(keys, iter.Val())
}
if err := iter.Err(); err != nil {
return err
}
if len(keys) > 0 {
return c.client.Del(ctx, keys...).Err()
}
return nil
}
Parte 7: La Capa HTTP — Router Chi y Handlers
// internal/interfaces/http/router.go
package http
import (
"log/slog"
"time"
"github.com/go-chi/chi/v5"
chimw "github.com/go-chi/chi/v5/middleware"
)
func NewRouter(handler *InvoiceHandler, logger *slog.Logger) *chi.Mux {
r := chi.NewRouter()
r.Use(chimw.RequestID)
r.Use(chimw.RealIP)
r.Use(NewStructuredLogger(logger))
r.Use(chimw.Recoverer)
r.Use(chimw.Timeout(30 * time.Second))
r.Use(chimw.Compress(5))
r.Get("/health", handler.HealthCheck)
r.Route("/api/v1", func(r chi.Router) {
r.Route("/invoices", func(r chi.Router) {
r.Post("/", handler.CreateInvoice)
r.Get("/", handler.ListInvoices)
r.Route("/{invoiceID}", func(r chi.Router) {
r.Get("/", handler.GetInvoice)
r.Patch("/status", handler.UpdateStatus)
r.Delete("/", handler.DeleteInvoice)
})
})
})
return r
}
Parte 8: Testing — TDD con Table-Driven Tests
// internal/domain/invoice_test.go
package domain
import (
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestNewInvoice(t *testing.T) {
validItems := []LineItem{
{Description: "Consulting", Quantity: 10, UnitPrice: 15000, TaxRate: 1600},
}
tomorrow := time.Now().Add(24 * time.Hour)
tests := []struct {
name string
number string
clientName string
clientEmail string
items []LineItem
dueAt time.Time
wantErr error
}{
{
name: "factura válida",
number: "INV-001",
clientName: "Acme Corp",
clientEmail: "billing@acme.com",
items: validItems,
dueAt: tomorrow,
wantErr: nil,
},
{
name: "número faltante",
number: "",
clientName: "Acme Corp",
clientEmail: "billing@acme.com",
items: validItems,
dueAt: tomorrow,
wantErr: ErrInvoiceNumberRequired,
},
{
name: "sin items",
number: "INV-001",
clientName: "Acme Corp",
clientEmail: "billing@acme.com",
items: []LineItem{},
dueAt: tomorrow,
wantErr: ErrItemsRequired,
},
{
name: "fecha vencida en el pasado",
number: "INV-001",
clientName: "Acme Corp",
clientEmail: "billing@acme.com",
items: validItems,
dueAt: time.Now().Add(-24 * time.Hour),
wantErr: ErrDueDateInPast,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
inv, err := NewInvoice(
tt.number, tt.clientName, tt.clientEmail,
"USD", "", tt.items, tt.dueAt,
)
if tt.wantErr != nil {
assert.ErrorIs(t, err, tt.wantErr)
assert.Nil(t, inv)
} else {
require.NoError(t, err)
assert.Equal(t, tt.number, inv.Number)
assert.Equal(t, StatusDraft, inv.Status)
}
})
}
}
func TestLineItemTotalCents(t *testing.T) {
tests := []struct {
name string
item LineItem
expected int64
}{
{
name: "10 horas a $150/hr con 16% impuesto",
item: LineItem{Quantity: 10, UnitPrice: 15000, TaxRate: 1600},
expected: 174000,
},
{
name: "1 item a $100 sin impuesto",
item: LineItem{Quantity: 1, UnitPrice: 10000, TaxRate: 0},
expected: 10000,
},
{
name: "5 items a $20 con 8% impuesto",
item: LineItem{Quantity: 5, UnitPrice: 2000, TaxRate: 800},
expected: 10800,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, tt.expected, tt.item.TotalCents())
})
}
}
func TestStatusTransitions(t *testing.T) {
tests := []struct {
name string
from InvoiceStatus
to InvoiceStatus
wantErr bool
}{
{"draft a sent", StatusDraft, StatusSent, false},
{"draft a cancelled", StatusDraft, StatusCancelled, false},
{"draft a paid (inválido)", StatusDraft, StatusPaid, true},
{"sent a paid", StatusSent, StatusPaid, false},
{"paid a draft (inválido)", StatusPaid, StatusDraft, true},
{"cancelled a sent (inválido)", StatusCancelled, StatusSent, true},
{"overdue a paid", StatusOverdue, StatusPaid, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
inv := &Invoice{Status: tt.from}
err := inv.TransitionTo(tt.to)
if tt.wantErr {
assert.ErrorIs(t, err, ErrStatusTransitionForbidden)
} else {
assert.NoError(t, err)
assert.Equal(t, tt.to, inv.Status)
}
})
}
}
Parte 9: BDD con Gherkin y Godog
# test/bdd/features/invoice.feature
Feature: Gestión de Facturas
Como dueño de negocio
Quiero gestionar facturas
Para poder rastrear pagos de clientes
Background:
Given el sistema está corriendo
And la base de datos está limpia
Scenario: Crear una factura válida
When creo una factura con:
| number | INV-001 |
| client | Acme Corp |
| email | billing@acme.com |
| currency | USD |
| due_date | 2026-12-31 |
Then la factura debería ser creada
And el status debería ser "draft"
Scenario: No se puede transicionar de paid a draft
Given una factura "INV-003" existe con status "paid"
When cambio el status a "draft"
Then debería ver un error "this status transition is not allowed"
Parte 10: Docker y Makefile
# docker-compose.yml
services:
api:
build: .
ports:
- "8080:8080"
environment:
DATABASE_URL: postgres://invoices:secret@db:5432/invoices?sslmode=disable
REDIS_ADDR: redis:6379
LOG_LEVEL: debug
depends_on:
db:
condition: service_healthy
redis:
condition: service_healthy
db:
image: postgres:17-alpine
environment:
POSTGRES_USER: invoices
POSTGRES_PASSWORD: secret
POSTGRES_DB: invoices
ports:
- "5432:5432"
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U invoices"]
interval: 5s
timeout: 5s
retries: 5
redis:
image: redis:7-alpine
ports:
- "6379:6379"
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 5s
retries: 5
volumes:
pgdata:
# Dockerfile
FROM golang:1.25-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o /api ./cmd/api
FROM alpine:3.21
RUN apk --no-cache add ca-certificates
COPY --from=builder /api /api
EXPOSE 8080
CMD ["/api"]
# Makefile
.PHONY: dev test lint build migrate docker-up docker-down
dev:
go run ./cmd/api
build:
go build -o bin/api ./cmd/api
test:
go test ./internal/... -v -race -count=1
test-bdd:
go test ./test/bdd/... -v
test-all: test test-bdd
lint:
golangci-lint run ./...
migrate-up:
migrate -path migrations -database "$$DATABASE_URL" up
migrate-down:
migrate -path migrations -database "$$DATABASE_URL" down 1
docker-up:
docker compose up -d
docker-down:
docker compose down
La Arquitectura Visualizada
graph TD
subgraph "Externo"
CLIENT[Cliente HTTP]
end
subgraph "Capa de Interfaces"
ROUTER[Chi Router + Middleware]
HANDLER[Invoice Handler]
end
subgraph "Capa de Aplicación"
CREATE[Crear Factura]
GET[Obtener Factura]
LIST[Listar Facturas]
STATUS[Actualizar Status]
end
subgraph "Capa de Dominio"
ENTITY[Entidad Invoice]
REPO_PORT[Interfaz Repositorio]
CACHE_PORT[Interfaz Cache]
end
subgraph "Capa de Infraestructura"
PG[Repositorio PostgreSQL]
REDIS[Cache Redis]
CONFIG[Carga de Config]
end
CLIENT --> ROUTER
ROUTER --> HANDLER
HANDLER --> CREATE
HANDLER --> GET
HANDLER --> LIST
HANDLER --> STATUS
CREATE --> ENTITY
CREATE --> REPO_PORT
CREATE --> CACHE_PORT
GET --> REPO_PORT
GET --> CACHE_PORT
STATUS --> ENTITY
STATUS --> REPO_PORT
REPO_PORT -.-> PG
CACHE_PORT -.-> REDIS
Las dependencias fluyen hacia adentro. El dominio nunca importa de infraestructura. La infraestructura implementa interfaces del dominio. La capa de aplicación coordina entre ellas. La capa HTTP traduce entre el mundo exterior y los comandos de aplicación. El punto de entrada conecta todas las piezas.
Por Qué Este Stack Funciona
Los paquetes en este proyecto no fueron elegidos por popularidad. Fueron elegidos por confiabilidad en producción.
Chi no reemplaza la librería estándar. La extiende. Tus handlers son http.HandlerFunc. Si remueves Chi mañana, las firmas de tus handlers no cambian.
pgx te da connection pooling, prepared statements, y tipos nativos de PostgreSQL sin un ORM. Escribes SQL. Entiendes tus consultas. El driver no esconde complejidad.
go-redis te da pipelining, connection pooling, y scripting Lua. Redis no es solo un cache en este proyecto. Es un sistema de invalidación por patrones que mantiene tu API rápida sin servir datos desactualizados.
slog es el logger estructurado de la librería estándar. Sin dependencia externa. Salida JSON en producción. Salida texto en desarrollo. Cada entrada de log tiene campos estructurados que tu sistema de monitoreo puede indexar.
caarlos0/env parsea variables de entorno en un struct tipado. Sin archivos YAML. Sin configuración JSON. Las variables de entorno funcionan en Docker, Kubernetes, CI, y desarrollo local sin cambios.
golang-migrate usa archivos SQL crudos. Tu DBA puede revisar migraciones. Sin código generado. Sin magia de ORM. Solo SQL.
El mejor stack de producción es aquel donde entiendes cada línea. Los frameworks esconden complejidad. Este proyecto la muestra. Cuando algo se rompe a las 3 AM, quieres leer el código y saber exactamente qué hace. Eso es lo que esta arquitectura te da.
Tags
Artículos relacionados
API Versioning Strategies: Cómo Evolucionar APIs sin Romper Clientes
Una guía exhaustiva sobre estrategias de versionado de APIs: URL versioning vs Header versioning, cómo deprecar endpoints sin shock, migration patterns reales, handling de cambios backwards-incompatibles, y decisiones arquitectónicas que importan. Con 50+ ejemplos de código en Go.
Arquitectura de software: Más allá del código
Una guía completa sobre arquitectura de software explicada en lenguaje humano: patrones, organización, estructura y cómo construir sistemas que escalen con tu negocio.
Automatizando tu vida con Go CLI: Guía profesional para crear herramientas de línea de comandos escalables
Una guía exhaustiva y paso a paso sobre cómo crear herramientas CLI escalables con Go 1.25.5: desde lo básico hasta proyectos empresariales complejos con flags, configuración, logging, y ejemplos prácticos para Windows y Linux.