Go 1.25 API en Producción: DDD, TDD, BDD, PostgreSQL, Redis — Setup Completo del Proyecto

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 Omar Flores

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/http sin reemplazarlo. Tus handlers son http.HandlerFunc está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 que if 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

#go #golang #rest-api #chi #ddd #domain-driven-design #tdd #bdd #testing #postgresql #redis #clean-code #architecture #backend #best-practices #performance