Domain-Driven Design en Go 1.25.5: Arquitectura Hexagonal, DDD Profundo de 0 a Experto

Domain-Driven Design en Go 1.25.5: Arquitectura Hexagonal, DDD Profundo de 0 a Experto

Guía completa sobre Domain-Driven Design en Go 1.25.5. Desde Value Objects hasta Agregados, Puertos, Adaptadores, Arquitectura Hexagonal, patrones profesionales, mejores prácticas, antipatrones y casos reales. De 0 a arquitecto experto.

Por Omar Flores

Tabla de Contenidos

  1. Introducción: Por Qué Existe DDD
  2. Value Objects: Los Bloques Básicos
  3. Entities: Objetos con Identidad
  4. Aggregates: Fronteras de Consistencia
  5. Domain Services: Lógica sin Hogar
  6. Ports & Adapters: Arquitectura Hexagonal
  7. Aplicación Completa: Todo Junto
  8. Antipatrones y Decisiones
  9. Conclusión y Checklist

Introducción: Por Qué Existe DDD {#introduccion}

El Problema que Nadie Menciona

Hace unos años, trabajabas en una startup. La aplicación era simple: usuarios, cuentas, transferencias. El código era limpio, rápido de escribir. Six meses después, tenías 50.000 líneas de código. Ahora, hacer un cambio simple en “transferencias” requería cambios en 15 archivos diferentes.

¿Qué salió mal?

La respuesta no está en Go, no está en los patrones de concurrencia, no está en la base de datos. La respuesta está en cómo pensaste sobre el problema desde el principio.

Programadores novatos suelen ver el código como un mapa directo de la base de datos:

// ANTIPATRÓN: Modelo Anémico (Anemic Domain Model)
type User struct {
    ID    int
    Name  string
    Email string
}

type Account struct {
    ID      int
    UserID  int
    Balance float64
}

func TransferMoney(fromID, toID int, amount float64) error {
    from := getAccount(fromID)
    to := getAccount(toID)

    if from.Balance < amount {
        return errors.New("insufficient funds")
    }

    from.Balance -= amount
    to.Balance += amount

    saveAccount(from)
    saveAccount(to)

    return nil
}

Este enfoque funciona al principio. Pero observa qué sucedió:

  1. La lógica está en funciones sueltas, no en objetos
  2. No hay validaciones en los datos (¿qué pasa si Balance es negativo?)
  3. El estado se manipula directamente sin protecciones
  4. No hay expresión del concepto de negocio (¿qué es una “transferencia”?)
  5. Un cambio en requisitos (ej: tasas de transferencia) dispara cambios en múltiples lugares

El Cambio de Mentalidad: De Técnico a Conceptual

Domain-Driven Design (DDD) es un cambio fundamental. No es un patrón. No es una arquitectura (aunque se puede aplicar en arquitectura). Es una filosofía de cómo pensar sobre los problemas.

La premisa central es radical:

“El código no debe modelar la base de datos. El código debe modelar el dominio del negocio.”

Esa diferencia es enorme.

En vez de pensar en tablas y columnas, piensas en conceptos del mundo real:

  • En banca, no hay “transacciones negativarias”. Existen depósitos y retiros.
  • En e-commerce, un carrito no es solo una lista de productos. Tiene validaciones de inventario, precios, impuestos.
  • En un sistema de reservas, no existe “reserva a la mitad”. Una reserva está pendiente, confirmada, cancelada, o completada.

Esta es la base de DDD: Expresar la realidad del negocio en código.

¿Por Qué Go es Perfecto para DDD?

Go no tiene herencias complejas. No tiene polimorfismo implícito. Go es aburrido, deliberadamente.

Eso es exactamente lo que necesitas en DDD.

Go te obliga a ser explícito:

// En Go, no hay trucos sintácticos. Todo es visible.
type Money struct {
    amount   int64 // en centavos
    currency string
}

// Si quieres que sea inmutable, lo dices explícitamente
func (m Money) Add(other Money) (Money, error) {
    if m.currency != other.currency {
        return Money{}, errors.New("incompatible currencies")
    }
    return Money{amount: m.amount + other.amount, currency: m.currency}, nil
}

Sin trucos de lenguaje, eres forzado a pensar claramente sobre lo que estás haciendo.

Tabla Comparativa: Anemic vs Rich Domain

AspectoModelo AnémicoRich Domain Model
EstructuraStructs sin métodosTipos con métodos
ValidaciónEn servicios sueltosEn el objeto mismo
Reglas de negocioEsparcidas en funcionesEncapsuladas en el tipo
MutabilidadCampos públicosMétodos controlados
TestabilidadDifícil (requiere mocks)Fácil (unidad es el objeto)
MantenibilidadBaja (dispersión lógica)Alta (lógica concentrada)
Para Go 1.25Anti-patrónRecomendado

Qué Aprenderás Aquí

Esta guía te llevará de forma gradual de “no sé qué es DDD” a “puedo diseñar sistemas complejos con confianza”.

Parte 1 (ahora): Conceptos fundamentales y filosofía.

Parte 2: Value Objects - Los ladrillos inmutables.

Parte 3: Entities - Objetos con ciclo de vida.

Parte 4: Aggregates - Fronteras de consistencia.

Parte 5: Domain Services - Lógica sin hogar.

Parte 6: Ports & Adapters - Arquitectura hexagonal.

Parte 7: Caso completo end-to-end.

Parte 8: Antipatrones y decisiones.


El Viaje Conceptual

Imagina que estás diseñando un sistema bancario. Aquí está el viaje que haremos:

┌─────────────────────────────────────────────────────────┐
│              SISTEMA BANCARIO                            │
├─────────────────────────────────────────────────────────┤
│                                                          │
│  Concepto: "Una transferencia mueve dinero entre     │
│             cuentas dentro de reglas específicas"        │
│                                                          │
│  Value Object (VO):                                      │
│    Money {Amount, Currency}                             │
│    Email {Address}                                       │
│    AccountNumber {Number}                                │
│                                                          │
│  Entity:                                                 │
│    BankAccount {ID, Holder, Balance}                    │
│                                                          │
│  Aggregate Root:                                         │
│    Transfer {ID, Source, Destination, Amount}           │
│    - Contiene las reglas de una transferencia            │
│    - Valida que sea posible                              │
│    - Emite eventos                                       │
│                                                          │
│  Domain Service:                                         │
│    TransferService.Execute(transfer)                     │
│    - Orquesta la lógica entre agregados                  │
│                                                          │
│  Ports:                                                  │
│    AccountRepository (guardar/cargar cuentas)           │
│    TransactionLog (registrar movimientos)                │
│                                                          │
│  Adapters:                                               │
│    PostgresAccountRepository                             │
│    KafkaTransactionLog                                   │
│                                                          │
└─────────────────────────────────────────────────────────┘

Cada nivel es más específico sobre las reglas del dominio.

Conceptos Clave en Una Sola Tabla

ConceptoPropósitoMutableTesteable
Value ObjectRepresentar valores mediblesNoSí, trivial
EntityObjeto con ciclo de vida únicoSí, con setup
AggregateFrontera de consistenciaSí, aislado
Aggregate RootPunto de acceso al agregadoSí, público
Domain ServiceLógica entre agregadosN/ASí, con mocks
RepositoryPersistencia (Puerto)N/ASí, fácil mock
Bounded ContextÁrea conceptual del sistemaN/AN/A

Primeros Pasos en Go 1.25.5

Vamos a establecer una estructura de proyecto que sostendrá nuestro diseño:

banking-system/
├── cmd/
│   └── main.go                 # Punto de entrada
├── domain/                      # Core del negocio
│   ├── account.go              # Entities
│   ├── money.go                # Value Objects
│   ├── transfer.go             # Agregates
│   └── service.go              # Domain Services
├── application/                 # Use cases
│   └── transfer_service.go      # Orquestación
├── infrastructure/              # Adaptadores
│   ├── postgres/                # Base de datos
│   └── kafka/                   # Message broker
├── ports/                        # Interfaces (puertos)
│   ├── repository.go
│   └── event_publisher.go
└── go.mod

En Go 1.25.5, esta estructura es clara, simple, y escalable.


GUÍA COMPLETA: Estructura de Archivos y Carpetas

Ahora vamos a desglosar exactamente dónde poner cada pieza del código DDD. Esta es la brújula que guiará todos los ejemplos que veremos:

Estructura Estándar Recomendada

banking-system/                    # Raíz del proyecto

├── go.mod                         # Módulo Go
├── go.sum
├── README.md

├── cmd/                            # ⚙️ ENTRY POINTS (Ejecutables)
│   ├── main.go                    # main() - inicia la aplicación
│   └── cli/
│       └── main.go                # CLI alternativa si la hay

├── domain/                         # ❤️ CORE DEL NEGOCIO (DDD Puro)
│   ├── account.go                 # Entities: BankAccount
│   ├── money.go                   # Value Objects: Money
│   ├── email.go                   # Value Objects: Email
│   ├── transfer.go                # Aggregates: Transfer
│   ├── repository.go              # Interfaces/Puertos: AccountRepository
│   ├── services.go                # Domain Services: TransferService
│   └── errors.go                  # Error types del dominio

├── application/                    # 🎯 CASOS DE USO (Orquestación)
│   ├── create_account.go          # UseCase: CreateAccountUseCase
│   ├── transfer_money.go          # UseCase: TransferMoneyUseCase
│   └── dto.go                     # Data Transfer Objects (requests/responses)

├── infrastructure/                 # 🔌 ADAPTADORES (Implementaciones concretas)
│   ├── postgres/                  # Adaptador PostgreSQL
│   │   ├── account_repository.go  # Implementa domain.AccountRepository
│   │   └── connection.go          # Setup de conexión
│   │
│   ├── kafka/                     # Adaptador Kafka
│   │   ├── event_publisher.go     # Implementa domain.EventPublisher
│   │   └── connection.go
│   │
│   ├── stripe/                    # Adaptador Stripe
│   │   ├── payment_gateway.go     # Implementa domain.PaymentGateway
│   │   └── client.go
│   │
│   └── http/                      # Adaptador HTTP (REST)
│       ├── router.go              # Setup de rutas
│       ├── handlers.go            # HTTP handlers
│       └── middleware.go          # Middleware HTTP

├── ports/                          # 📋 EXPORTACIÓN DE PUERTOS (Interfaces públicas)
│   ├── repository.go              # re-export domain.AccountRepository
│   ├── events.go                  # re-export domain.EventPublisher
│   └── gateway.go                 # re-export domain.PaymentGateway

├── config/                         # ⚙️ CONFIGURACIÓN
│   └── config.go                  # Carga de .env y configuración

└── tests/                          # ✅ TESTS
    ├── integration/               # Tests de integración
    └── fixtures/                  # Datos de prueba

Convenciones de Nombres

ConceptoUbicaciónNombre del ArchivoConvención
Entitydomain/nombre.gotype NombreEntity struct
Value Objectdomain/nombre.gotype NombreVO struct
Aggregatedomain/nombre.gotype NombreAggregate struct
ID de Entitydomain/nombre.gotype NombreID string
Puerto (Interface)domain/nombre.gotype NombreRepository interface
Domain Servicedomain/services.gotype NombreService struct
Use Caseapplication/action.gotype ActionUseCase struct
Adaptadorinfrastructure/adapter/nombre_adapter.gotype NombreAdapter struct
Handler HTTPinfrastructure/http/handlers.gofunc (h *Handler) HandleNoun(w, r)
DTOapplication/dto.gotype NounRequest/Response struct

Regla de Oro: Cómo Separar en Archivos

La regla es simple: UN CONCEPTO POR ARCHIVO (generalmente)

✓ BIEN                          ❌ MALO
domain/money.go                 domain/everything.go
  - Money struct                  - Money struct
  - NewMoney()                    - Email struct
                                  - User struct
                                  - NewMoney()
                                  - NewEmail()
                                  - NewUser()
                                  - TransferService
                                  - (TODO mezclado)

Excepciones a la regla:

  • Relacionados cercanos: Si Money y Currency son inseparables, pueden ir en el mismo archivo
  • Value Objects pequeños: Si tienes 5 Value Objects pequeños, pueden compartir archivo
  • Interfaces pequeñas: repository.go puede tener múltiples interfaces relacionadas
  • DTOs: Todos los DTOs de una feature van en application/dto.go

Ejemplo Completo: Money Value Object

// domain/money.go
// ================
// Ubicación: src/domain/money.go
// Concepto: Money Value Object
// Responsabilidad: Representar dinero con validación

package domain

import (
    "errors"
    "fmt"
)

// Money representa una cantidad de dinero
// Archivo: domain/money.go
type Money struct {
    amount   int64  // centavos
    currency string
}

func NewMoney(amount int64, currency string) (Money, error) {
    // validación...
    return Money{amount: amount, currency: currency}, nil
}

// ... métodos Add, Subtract, etc

Cómo importarlo desde otra parte:

// application/transfer_money.go
package application

import (
    "banking-system/domain"  // ← La ruta depende de dónde estés
)

func UseIt() {
    m, _ := domain.NewMoney(1000, "USD")
}

Ejemplo: Entity BankAccount

// domain/account.go
// ==================
// Ubicación: src/domain/account.go
// Concepto: BankAccount Entity
// Responsabilidad: Validar transacciones, mantener balance

package domain

import (
    "errors"
    "time"
)

type AccountID string

type Account struct {
    id        AccountID
    balance   Money
    status    AccountStatus
    createdAt time.Time
}

func NewAccount(id AccountID, initialBalance Money) (Account, error) {
    // validación
    return Account{
        id:        id,
        balance:   initialBalance,
        status:    AccountStatusActive,
        createdAt: time.Now(),
    }, nil
}

// ... métodos Deposit, Withdraw, etc

Ejemplo: Puerto (Interface)

// domain/repository.go
// ====================
// Ubicación: src/domain/repository.go
// Concepto: Puertos (Interfaces)
// Responsabilidad: Definir contratos que la infraestructura implementará

package domain

// AccountRepository es un puerto para persistencia
type AccountRepository interface {
    FindByID(id AccountID) (*Account, error)
    Save(account *Account) error
    Delete(id AccountID) error
}

// EventPublisher es otro puerto
type EventPublisher interface {
    Publish(event DomainEvent) error
}

// PaymentGateway es otro puerto
type PaymentGateway interface {
    Charge(amount Money) error
    Refund(amount Money) error
}

Por qué aquí y no en infrastructure:

  • El dominio define lo que necesita (puertos)
  • La infraestructura implementa cómo (adaptadores)
  • Esto invierte las dependencias correctamente

Ejemplo: Domain Service

// domain/services.go
// ===================
// Ubicación: src/domain/services.go
// Concepto: Orquestadores de lógica entre agregados
// Responsabilidad: Coordinar múltiples agregados

package domain

type TransferService struct {
    accountRepo AccountRepository
    eventPub    EventPublisher
}

func NewTransferService(
    repo AccountRepository,
    eventPub EventPublisher,
) *TransferService {
    return &TransferService{
        accountRepo: repo,
        eventPub:    eventPub,
    }
}

func (ts *TransferService) Transfer(
    fromID AccountID,
    toID AccountID,
    amount Money,
) error {
    // Lógica que toca múltiples agregados
    // Usa los puertos inyectados
    return nil
}

Ejemplo: Use Case (Application Service)

// application/transfer_money.go
// ==============================
// Ubicación: src/application/transfer_money.go
// Concepto: Use Case / Application Service
// Responsabilidad: Orquestar y hacer transacciones

package application

import (
    "banking-system/domain"
)

type TransferMoneyRequest struct {
    FromAccountID string
    ToAccountID   string
    AmountCents   int64
}

type TransferMoneyResponse struct {
    Success bool
    Message string
}

type TransferMoneyUseCase struct {
    transferService *domain.TransferService
}

func NewTransferMoneyUseCase(
    transferService *domain.TransferService,
) *TransferMoneyUseCase {
    return &TransferMoneyUseCase{
        transferService: transferService,
    }
}

func (uc *TransferMoneyUseCase) Execute(req TransferMoneyRequest) (*TransferMoneyResponse, error) {
    // Convertir request a dominio
    amount, _ := domain.NewMoney(req.AmountCents, "USD")

    // Usar Domain Service
    err := uc.transferService.Transfer(
        domain.AccountID(req.FromAccountID),
        domain.AccountID(req.ToAccountID),
        amount,
    )

    if err != nil {
        return nil, err
    }

    return &TransferMoneyResponse{
        Success: true,
        Message: "Transfer completed",
    }, nil
}

Ejemplo: Adaptador PostgreSQL

// infrastructure/postgres/account_repository.go
// ===============================================
// Ubicación: src/infrastructure/postgres/account_repository.go
// Concepto: Implementación de domain.AccountRepository
// Responsabilidad: Persistencia en PostgreSQL

package postgres

import (
    "database/sql"
    "banking-system/domain"
)

type AccountRepository struct {
    db *sql.DB
}

func NewAccountRepository(db *sql.DB) *AccountRepository {
    return &AccountRepository{db: db}
}

// Implementar interfaz domain.AccountRepository
func (ar *AccountRepository) FindByID(id domain.AccountID) (*domain.Account, error) {
    row := ar.db.QueryRow(
        "SELECT id, balance_cents, status FROM accounts WHERE id = $1",
        string(id),
    )

    var accID string
    var balanceCents int64
    var status string

    if err := row.Scan(&accID, &balanceCents, &status); err != nil {
        return nil, err
    }

    // Convertir datos crudos a dominio
    balance, _ := domain.NewMoney(balanceCents, "USD")
    account, _ := domain.NewAccount(domain.AccountID(accID), balance)

    return &account, nil
}

func (ar *AccountRepository) Save(account *domain.Account) error {
    query := `
        INSERT INTO accounts (id, balance_cents, status)
        VALUES ($1, $2, $3)
        ON CONFLICT (id) DO UPDATE SET balance_cents = $2
    `

    _, err := ar.db.Exec(
        query,
        string(account.ID()),
        account.Balance().Amount(),
        "active",
    )

    return err
}

Ejemplo: Handler HTTP

// infrastructure/http/handlers.go
// ================================
// Ubicación: src/infrastructure/http/handlers.go
// Concepto: HTTP Adapters / REST Handlers
// Responsabilidad: Traducir HTTP ↔ Use Cases

package http

import (
    "encoding/json"
    "net/http"
    "banking-system/application"
)

type TransferHandler struct {
    useCase *application.TransferMoneyUseCase
}

func NewTransferHandler(uc *application.TransferMoneyUseCase) *TransferHandler {
    return &TransferHandler{useCase: uc}
}

func (h *TransferHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
    if r.Method != http.MethodPost {
        http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
        return
    }

    var req application.TransferMoneyRequest
    if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
        http.Error(w, "Invalid request", http.StatusBadRequest)
        return
    }

    resp, err := h.useCase.Execute(req)
    if err != nil {
        http.Error(w, err.Error(), http.StatusBadRequest)
        return
    }

    w.Header().Set("Content-Type", "application/json")
    json.NewEncoder(w).Encode(resp)
}

Ejemplo: Main Composition Root

// cmd/main.go
// ===========
// Ubicación: src/cmd/main.go
// Concepto: Entry Point y Composición de dependencias
// Responsabilidad: Instanciar, inyectar, ejecutar

package main

import (
    "database/sql"
    "log"
    "net/http"

    _ "github.com/lib/pq"
    "banking-system/application"
    "banking-system/domain"
    "banking-system/infrastructure/postgres"
    "banking-system/infrastructure/http"
)

func main() {
    // 1. Conectar a BD
    db, err := sql.Open("postgres", "postgresql://user:pass@localhost/bank")
    if err != nil {
        log.Fatalf("Failed to open database: %v", err)
    }
    defer db.Close()

    // 2. Instanciar adaptadores
    accountRepo := postgres.NewAccountRepository(db)
    transferService := domain.NewTransferService(accountRepo, nil)

    // 3. Instanciar use cases
    transferUC := application.NewTransferMoneyUseCase(transferService)

    // 4. Instanciar handlers
    transferHandler := http.NewTransferHandler(transferUC)

    // 5. Registrar rutas
    http.Handle("/transfer", transferHandler)

    // 6. Escuchar
    log.Println("Server listening on :8080")
    if err := http.ListenAndServe(":8080", nil); err != nil {
        log.Fatalf("Server error: %v", err)
    }
}

Cómo Organizar Múltiples Agregados

Si tienes MÚLTIPLES agregados (Order, Inventory, Payment), organiza así:

domain/
├── account/
│   ├── account.go          # Account Entity
│   ├── money.go            # Money VO
│   ├── repository.go       # AccountRepository interface
│   └── service.go          # TransferService

├── order/
│   ├── order.go            # Order Entity
│   ├── line_item.go        # LineItem VO
│   ├── repository.go       # OrderRepository interface
│   └── service.go          # OrderService

├── inventory/
│   ├── stock.go            # Stock Entity
│   ├── sku.go              # SKU VO
│   ├── repository.go       # StockRepository interface
│   └── service.go          # InventoryService

└── shared/
    ├── id_generator.go
    └── errors.go

O si es muy simple, todo en el root:

domain/
├── account.go
├── order.go
├── inventory.go
├── repository.go
├── services.go
└── errors.go

Testing: Dónde Poner los Tests

project/
├── domain/
│   ├── account.go
│   └── account_test.go        # ← Tests aquí

├── application/
│   ├── transfer_money.go
│   └── transfer_money_test.go # ← Tests aquí

├── infrastructure/
│   ├── postgres/
│   │   ├── account_repository.go
│   │   └── account_repository_test.go  # ← Tests aquí
│   └── http/
│       ├── handlers.go
│       └── handlers_test.go   # ← Tests aquí

└── tests/
    ├── integration/           # ← Tests de integración aquí
    │   └── transfer_flow_test.go
    └── fixtures/              # ← Datos de prueba
        └── accounts.go

Regla: Archivos de test van en el mismo paquete, con sufijo _test.go


Por Qué Este Enfoque Importa Ahora

Go 1.25 añadió mejoras que hacen DDD más natural:

  • Iteradores (iter.Seq): Perfectos para colecciones inmutables
  • Generics maduros: Type-safe aggregates
  • sync.OnceValue: Lazily initialized value objects

Versiones anteriores eran más verbosas. Go 1.25 hace el código más limpio y expresivo.


Resumen de Parte 1

  • DDD es pensar en el problema del negocio, no en la base de datos
  • Rich Domain Models encapsulan lógica; Anemic Models la dispersan
  • Go es perfecto para DDD porque te obliga a ser explícito
  • Aprenderemos Value Objects, Entities, Agregates, Services, Puertos, y Adaptadores
  • Go 1.25.5 hace esto aún más elegante

En la siguiente sección, comenzaremos con Value Objects, los ladrillos más pequeños pero más fundamentales de un sistema DDD.


Parte 2: Value Objects - Los Ladrillos Fundamentales {#value-objects}

La Revolución Silenciosa de los Value Objects

Un Value Object es quizá el concepto más simple pero más transformador de DDD. No es complicado. Pero es profundo.

Definición:

Un Value Object es un objeto que no tiene identidad única. Lo que importa es su contenido, no quién es.

Ejemplo mental:

  • Entity: Tu pasaporte. Tiene un número único. Es único incluso si los datos cambian. Es una identidad.
  • Value Object: El dinero en tu billetera. 50 dólares no es diferente de otros 50 dólares. Lo que importa es el valor.

El Dinero: El Value Object Más Importante

En sistemas reales, el dinero es casi siempre un Value Object. Empecemos ahí.

// ❌ ANTIPATRÓN: VERSIÓN NAIVE (NO HAGAS ESTO)
// Evita: Usar float64 para dinero sin validación
type Account struct {
    Balance float64 // ¿Qué pasa si es -500? ¿En qué moneda?
}

// ✓ BIEN: VERSIÓN DDD
// Archivo: domain/money.go
// Concepto: Money Value Object - Representa dinero inmutable con validación
type Money struct {
    amount   int64  // En centavos para evitar punto flotante
    currency string // USD, EUR, MXN, etc.
}

¿Por qué cambió todo? Porque ahora el concepto de “dinero” existe en el código. No es solo un número.

Veamos la implementación completa:

// Archivo: domain/money.go
// Paquete: domain
// Responsabilidad: Todas las operaciones relacionadas con Money

package domain

import (
    "errors"
    "fmt"
)

// Money representa una cantidad de dinero con su moneda.
// Es INMUTABLE: una vez creado, no cambia.
type Money struct {
    amount   int64  // Centavos (no float para evitar imprecisión)
    currency string // "USD", "EUR", "MXN"
}

// NewMoney crea un nuevo Money validando sus precondiciones
func NewMoney(amount int64, currency string) (Money, error) {
    // Validación 1: La cantidad no puede ser negativa
    if amount < 0 {
        return Money{}, errors.New("money amount cannot be negative")
    }

    // Validación 2: La moneda debe ser válida
    if !isValidCurrency(currency) {
        return Money{}, fmt.Errorf("invalid currency: %s", currency)
    }

    return Money{amount: amount, currency: currency}, nil
}

// Getter para leer el monto
func (m Money) Amount() int64 {
    return m.amount
}

// Getter para leer la moneda
func (m Money) Currency() string {
    return m.currency
}

// Add suma dos monedas si tienen la misma moneda
// IMPORTANTE: No modifica el Money existente, retorna uno nuevo
func (m Money) Add(other Money) (Money, error) {
    if m.currency != other.currency {
        return Money{}, fmt.Errorf(
            "cannot add different currencies: %s and %s",
            m.currency,
            other.currency,
        )
    }

    newAmount := m.amount + other.amount
    return Money{amount: newAmount, currency: m.currency}, nil
}

// Subtract resta dinero. Si quedaría negativo, error.
func (m Money) Subtract(other Money) (Money, error) {
    if m.currency != other.currency {
        return Money{}, fmt.Errorf(
            "cannot subtract different currencies: %s and %s",
            m.currency,
            other.currency,
        )
    }

    if other.amount > m.amount {
        return Money{}, errors.New("insufficient funds")
    }

    newAmount := m.amount - other.amount
    return Money{amount: newAmount, currency: m.currency}, nil
}

// Equals: Dos Money son iguales si tienen mismo amount y currency
func (m Money) Equals(other Money) bool {
    return m.amount == other.amount && m.currency == other.currency
}

// IsZero: ¿Es cero dinero?
func (m Money) IsZero() bool {
    return m.amount == 0
}

// IsGreaterThan: Comparación (solo con misma moneda)
func (m Money) IsGreaterThan(other Money) (bool, error) {
    if m.currency != other.currency {
        return false, fmt.Errorf("cannot compare different currencies")
    }
    return m.amount > other.amount, nil
}

// String: Representación legible
func (m Money) String() string {
    // Convertir centavos a formato decimal
    dollars := m.amount / 100
    cents := m.amount % 100
    return fmt.Sprintf("%s %.2f", m.currency, float64(dollars) + float64(cents)/100)
}

// Helper privado para validar monedas
func isValidCurrency(currency string) bool {
    validCurrencies := map[string]bool{
        "USD": true,
        "EUR": true,
        "MXN": true,
        "GBP": true,
        "JPY": true,
    }
    return validCurrencies[currency]
}

Ahora veamos cómo usar Money:

// Archivo: domain/money_example.go (o tests: domain/money_test.go)
// Uso correcto de Money Value Object

package domain

// Ejemplo de uso
func ExampleMoney() {
    // Crear 100 dólares
    balance, _ := NewMoney(10000, "USD") // 10000 centavos = $100.00

    // Agregar dinero
    deposit, _ := NewMoney(5000, "USD")
    newBalance, _ := balance.Add(deposit)

    fmt.Println(newBalance) // Output: USD 150.00

    // Las operaciones no mutan el original
    fmt.Println(balance)    // Output: USD 100.00 (sin cambios)

    // Restar dinero
    withdrawal, _ := NewMoney(3000, "USD")
    finalBalance, _ := newBalance.Subtract(withdrawal)
    fmt.Println(finalBalance) // Output: USD 120.00
}

¿Ves lo que pasó? Cada operación valida automáticamente. No hay forma de:

  • Crear dinero negativo
  • Mezclar monedas sin un error claro
  • Modificar el Money existente

Otro Value Object Crucial: Email

Email es otro Value Object importante en cualquier sistema. Aquí está:

// Archivo: domain/email.go
// Paquete: domain
// Concepto: Email Value Object - Email validado e inmutable

package domain

import (
    "errors"
    "regexp"
    "strings"
)

// Email representa una dirección de correo electrónico válida
// Es INMUTABLE y siempre está en forma normalizada
type Email struct {
    value string // siempre minúscula
}

// NewEmail crea y valida un email
func NewEmail(email string) (Email, error) {
    // Normalizar: minúsculas y sin espacios
    email = strings.ToLower(strings.TrimSpace(email))

    // Validar formato básico
    if !isValidEmailFormat(email) {
        return Email{}, errors.New("invalid email format")
    }

    return Email{value: email}, nil
}

// String retorna el email como string
func (e Email) String() string {
    return e.value
}

// Equals compara dos emails
func (e Email) Equals(other Email) bool {
    return e.value == other.value
}

// LocalPart retorna la parte anterior a @
func (e Email) LocalPart() string {
    parts := strings.Split(e.value, "@")
    return parts[0]
}

// Domain retorna la parte posterior a @
func (e Email) Domain() string {
    parts := strings.Split(e.value, "@")
    if len(parts) > 1 {
        return parts[1]
    }
    return ""
}

// Helper privado
func isValidEmailFormat(email string) bool {
    pattern := `^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$`
    regex := regexp.MustCompile(pattern)
    return regex.MatchString(email) && len(email) <= 254
}

Value Objects Más Complejos: Coordinates

Algunos Value Objects tienen múltiples dimensiones. Por ejemplo, coordenadas geográficas:

// Archivo: domain/coordinates.go
// Paquete: domain
// Concepto: Coordinates Value Object para ubicaciones geográficas

package domain

import (
    "errors"
    "math"
)

// Coordinates representa una posición geográfica (latitud, longitud)
// Es INMUTABLE
type Coordinates struct {
    latitude  float64 // -90 a 90
    longitude float64 // -180 a 180
}

// NewCoordinates crea coordenadas validadas
func NewCoordinates(lat, lon float64) (Coordinates, error) {
    if lat < -90 || lat > 90 {
        return Coordinates{}, errors.New("latitude must be between -90 and 90")
    }
    if lon < -180 || lon > 180 {
        return Coordinates{}, errors.New("longitude must be between -180 and 180")
    }

    return Coordinates{latitude: lat, longitude: lon}, nil
}

// Getters
func (c Coordinates) Latitude() float64  { return c.latitude }
func (c Coordinates) Longitude() float64 { return c.longitude }

// Equals compara dos coordenadas (con pequeña tolerancia)
func (c Coordinates) Equals(other Coordinates) bool {
    const epsilon = 0.000001
    return math.Abs(c.latitude-other.latitude) < epsilon &&
           math.Abs(c.longitude-other.longitude) < epsilon
}

// DistanceTo calcula distancia en km (fórmula de Haversine)
func (c Coordinates) DistanceTo(other Coordinates) float64 {
    const earthRadiusKm = 6371.0

    lat1 := toRadians(c.latitude)
    lat2 := toRadians(other.latitude)
    deltaLat := toRadians(other.latitude - c.latitude)
    deltaLon := toRadians(other.longitude - c.longitude)

    a := math.Sin(deltaLat/2)*math.Sin(deltaLat/2) +
         math.Cos(lat1)*math.Cos(lat2)*
             math.Sin(deltaLon/2)*math.Sin(deltaLon/2)

    c_val := 2 * math.Atan2(math.Sqrt(a), math.Sqrt(1-a))

    return earthRadiusKm * c_val
}

// Helper
func toRadians(degrees float64) float64 {
    return degrees * math.Pi / 180
}

Las Tres Características Vitales de Value Objects

// 1. INMUTABILIDAD: No se pueden cambiar tras su creación
type BadMoney struct {
    Amount int64 // ❌ Público, puede cambiar
}

type GoodMoney struct {
    amount int64 // ✓ Privado, no puede cambiar
}

// 2. VALIDACIÓN EN LA CONSTRUCCIÓN: Imposible crear inválidos
type BadEmail struct {
    Value string // ❌ Puede ser cualquier cosa
}

func NewBadEmail(email string) BadEmail {
    return BadEmail{Value: email}
}

type GoodEmail struct {
    value string
}

func NewGoodEmail(email string) (GoodEmail, error) {
    if !isValidEmail(email) {
        return GoodEmail{}, errors.New("invalid")
    }
    return GoodEmail{value: email}, nil
}

// 3. COMPARACIÓN POR VALOR, NO POR IDENTIDAD
func TestComparison() {
    money1, _ := NewMoney(10000, "USD")
    money2, _ := NewMoney(10000, "USD")

    // money1 y money2 son instancias diferentes
    // Pero son IGUALES en concepto
    if money1.Equals(money2) {
        fmt.Println("Same value, different objects") // ✓
    }
}

Cuándo Usar Value Objects: La Regla de Oro

Si…Entonces es VO
Validas en construcción
Campos privados
Immutable
Comparación por valor
Sin identidad única
Tienes identidad única✗ Entity, no VO
Es mutable✗ Entity, no VO
No validas al crear✗ Antipatrón

Antipatrón 1: Value Objects Mutables

// ❌ MALO: Money mutable
type BadMoney struct {
    Amount int64
}

func (m *BadMoney) Add(amount int64) {
    m.Amount += amount // Cambia el objeto existente
}

// Esto es problemático:
balance := BadMoney{Amount: 100}
backup := balance
balance.Add(50)

fmt.Println(balance.Amount, backup.Amount) // 150, 150 - ¡¡backup cambió!!

// ✓ BIEN: Money inmutable
type GoodMoney struct {
    amount int64
}

func (m GoodMoney) Add(amount int64) GoodMoney {
    return GoodMoney{amount: m.amount + amount}
}

balance := GoodMoney{amount: 100}
backup := balance
balance = balance.Add(50)

fmt.Println(balance.Amount, backup.Amount) // 150, 100 - ✓ Correcto

Antipatrón 2: Sin Validación en Construcción

// ❌ MALO: Dinero sin validación
type BadMoney struct {
    Amount   int64
    Currency string
}

func NewBadMoney(amount int64, currency string) BadMoney {
    return BadMoney{Amount: amount, Currency: currency}
}

// Esto permite:
m := NewBadMoney(-500, "INVALID") // ¡¡Dinero negativo e inválido!!

// ✓ BIEN: Con validación
type GoodMoney struct {
    amount   int64
    currency string
}

func NewGoodMoney(amount int64, currency string) (GoodMoney, error) {
    if amount < 0 {
        return GoodMoney{}, errors.New("amount cannot be negative")
    }
    if !isValidCurrency(currency) {
        return GoodMoney{}, errors.New("invalid currency")
    }
    return GoodMoney{amount: amount, currency: currency}, nil
}

Antipatrón 3: Métodos que Violan Comparación

// ❌ MALO: Comparación basada en referencia
type BadMoney struct {
    amount int64
    id     string // ID para comparar
}

func (m BadMoney) Equals(other BadMoney) bool {
    return m.id == other.id // ¡¡Comparación por identidad, no valor!!
}

// ✓ BIEN: Comparación por valor
type GoodMoney struct {
    amount   int64
    currency string
}

func (m GoodMoney) Equals(other GoodMoney) bool {
    return m.amount == other.amount && m.currency == other.currency
}

Generics en Go 1.25 para Value Objects

Go 1.25 permite crear Value Objects genéricos. Aquí está una versión sofisticada:

package domain

import "errors"

// VO es un patrón genérico para Value Objects
// T es el tipo subyacente
type VO[T comparable] struct {
    value T
}

// NewVO crea un Value Object con validación
func NewVO[T comparable](value T, validator func(T) error) (VO[T], error) {
    if err := validator(value); err != nil {
        return VO[T]{}, err
    }
    return VO[T]{value: value}, nil
}

// Get retorna el valor
func (v VO[T]) Get() T {
    return v.value
}

// Equals compara dos VOs
func (v VO[T]) Equals(other VO[T]) bool {
    return v.value == other.value
}

// Ejemplo de uso:
func ExampleGenericVO() {
    // Crear un VO para strings con longitud máxima
    maxLength := func(value string) error {
        if len(value) > 100 {
            return errors.New("too long")
        }
        return nil
    }

    username, _ := NewVO("john_doe", maxLength)
    fmt.Println(username.Get()) // john_doe
}

Testing Value Objects (Extremadamente Simple)

Aquí está lo bello de los Value Objects: son tan simples que probarlos es trivial:

// Archivo: domain/money_test.go
// Paquete: domain
// Responsabilidad: Tests para Money Value Object

package domain

import (
    "testing"
)

func TestMoneyCreation(t *testing.T) {
    tests := []struct {
        amount      int64
        currency    string
        shouldError bool
    }{
        {10000, "USD", false},    // ✓
        {-100, "USD", true},      // Negativo
        {100, "INVALID", true},   // Moneda inválida
        {0, "EUR", false},        // ✓ Cero es válido
    }

    for _, tt := range tests {
        _, err := NewMoney(tt.amount, tt.currency)
        if (err != nil) != tt.shouldError {
            t.Fatalf("NewMoney(%d, %q): expected error %v, got %v",
                tt.amount, tt.currency, tt.shouldError, err)
        }
    }
}

func TestMoneyAddition(t *testing.T) {
    m1, _ := NewMoney(10000, "USD")
    m2, _ := NewMoney(5000, "USD")

    result, err := m1.Add(m2)
    if err != nil {
        t.Fatalf("unexpected error: %v", err)
    }

    expected, _ := NewMoney(15000, "USD")
    if !result.Equals(expected) {
        t.Fatalf("expected %v, got %v", expected, result)
    }
}

func TestMoneySubtraction_InsufficientFunds(t *testing.T) {
    m1, _ := NewMoney(5000, "USD")
    m2, _ := NewMoney(10000, "USD")

    _, err := m1.Subtract(m2)
    if err == nil {
        t.Fatal("expected error for insufficient funds")
    }
}

func TestEmailNormalization(t *testing.T) {
    email1, _ := NewEmail("JOHN@EXAMPLE.COM")
    email2, _ := NewEmail("john@example.com")

    if !email1.Equals(email2) {
        t.Fatal("emails should be normalized")
    }
}

Resumen de Parte 2

  • Value Objects encapsulan concepto, no identidad
  • Siempre son inmutables y validados en construcción
  • La comparación se hace por valor, no por referencia
  • Ejemplos clásicos: Money, Email, Coordinates
  • Son triviales de testear
  • Go 1.25 permite Value Objects genéricos con interfaces claras

Próximos Pasos

Ahora que entiendes Value Objects (el nivel más básico), estamos listos para el siguiente nivel: Entities. Las Entities son como Value Objects, pero con identidad única y ciclo de vida.


Parte 3: Entities - Objetos con Identidad y Ciclo de Vida {#entities}

La Diferencia Fundamental

Si los Value Objects son “qué”, las Entities son “quién”.

  • Value Object: 50 dólares. ¿Cuáles 50 dólares? No importa, son 50 dólares.
  • Entity: Tu cuenta bancaria. Tiene un número único. Es tu cuenta, incluso si el saldo cambia.
// ❌ MALO: Money como Entity (error conceptual)
type BankAccount struct {
    ID      string  // Identidad
    Balance float64 // Money
}

// Esto es confuso: Money no tiene identidad, pero Account sí.

// ✓ BIEN: Separar conceptos
type BankAccount struct {
    id      string // Identidad única de la Entity
    balance Money  // Compuesto de un Value Object
}

// La identidad NO cambia. El balance SÍ.

La Anatomía de una Entity

Una Entity en Go 1.25 tiene esta estructura:

package domain

import (
    "errors"
    "time"
)

// UserID es un Value Object para la identidad de User
type UserID struct {
    value string
}

func NewUserID(value string) (UserID, error) {
    if value == "" {
        return UserID{}, errors.New("user id cannot be empty")
    }
    return UserID{value: value}, nil
}

func (uid UserID) String() string {
    return uid.value
}

func (uid UserID) Equals(other UserID) bool {
    return uid.value == other.value
}

// ===== ENTITY =====

// Archivo: domain/user.go
// Paquete: domain
// Concepto: User Entity - Objeto con identidad y ciclo de vida

// User es una Entity: tiene identidad única y ciclo de vida
type User struct {
    // IDENTIDAD (nunca cambia)
    id UserID

    // DATOS (pueden cambiar)
    email    Email
    name     string
    status   UserStatus
    joinedAt time.Time

    // TIMESTAMPS (auditoria)
    createdAt time.Time
    updatedAt time.Time
}

// UserStatus es un enum para el estado de la cuenta
type UserStatus string

const (
    UserStatusActive       UserStatus = "active"
    UserStatusInactive     UserStatus = "inactive"
    UserStatusSuspended    UserStatus = "suspended"
    UserStatusUnderReview  UserStatus = "under_review"
)

// Constructor: La única forma de crear una User válida
func NewUser(id UserID, email Email, name string) (User, error) {
    if name == "" {
        return User{}, errors.New("name cannot be empty")
    }

    now := time.Now()
    return User{
        id:        id,
        email:     email,
        name:      name,
        status:    UserStatusActive,
        joinedAt:  now,
        createdAt: now,
        updatedAt: now,
    }, nil
}

// ===== GETTERS (por identidad) =====

// ID retorna la identidad de la User (que nunca cambia)
func (u User) ID() UserID {
    return u.id
}

// Email retorna el email actual
func (u User) Email() Email {
    return u.email
}

// Name retorna el nombre actual
func (u User) Name() string {
    return u.name
}

// Status retorna el estado actual
func (u User) Status() UserStatus {
    return u.status
}

// ===== MÉTODOS DE NEGOCIO (cambios de estado) =====

// UpdateEmail cambia el email validando precondiciones
func (u *User) UpdateEmail(newEmail Email) error {
    if u.status == UserStatusSuspended {
        return errors.New("cannot update email of suspended user")
    }

    u.email = newEmail
    u.updatedAt = time.Now()
    return nil
}

// Suspend suspende la cuenta
func (u *User) Suspend(reason string) error {
    if u.status == UserStatusSuspended {
        return errors.New("user already suspended")
    }

    u.status = UserStatusSuspended
    u.updatedAt = time.Now()
    // En un caso real, aquí registrarías la razón en event sourcing
    return nil
}

// Reactivate reactiva la cuenta
func (u *User) Reactivate() error {
    if u.status != UserStatusSuspended {
        return errors.New("can only reactivate suspended users")
    }

    u.status = UserStatusActive
    u.updatedAt = time.Now()
    return nil
}

// IsActive: Helper para lógica común
func (u User) IsActive() bool {
    return u.status == UserStatusActive
}

// ===== IDENTIDAD =====

// EntityEquals compara dos User por su IDENTIDAD (ID), no sus valores
// Esto es diferente de Value Objects
func (u User) EntityEquals(other User) bool {
    return u.id.Equals(other.id)
}

El Concepto de Mutabilidad Controlada

Las Entities SÍ pueden cambiar, pero de forma controlada. Observa la diferencia:

// ❌ MALO: Campos públicos (mutación sin control)
type BadUser struct {
    ID     string
    Name   string
    Status string
}

user := BadUser{ID: "1", Name: "John", Status: "active"}
user.Status = "INVALID_STATUS" // Nada detiene esto
user.Status = "suspended" // Cambio silencioso, sin validación

// ✓ BIEN: Métodos para cambios validados
type GoodUser struct {
    id     string
    name   string
    status UserStatus
}

func (u *GoodUser) Suspend() error {
    if u.status == UserStatusSuspended {
        return errors.New("already suspended")
    }
    u.status = UserStatusSuspended
    return nil
}

user := GoodUser{id: "1", name: "John", status: UserStatusActive}
if err := user.Suspend(); err != nil {
    // Maneja el error
}

Entities Complejas: Orden de E-commerce

Una orden es más compleja. Contiene múltiples líneas, cada una con un producto y cantidad:

package domain

import (
    "errors"
    "time"
)

// OrderID es la identidad de una Order
type OrderID struct {
    value string
}

func NewOrderID(value string) (OrderID, error) {
    if value == "" {
        return OrderID{}, errors.New("order id cannot be empty")
    }
    return OrderID{value: value}, nil
}

// OrderLineItem representa una línea de la orden
// Nota: podría ser un Entity propio o un Value Object según el contexto
type OrderLineItem struct {
    productID   string
    quantity    int
    unitPrice   Money
    description string
}

func NewOrderLineItem(
    productID string,
    quantity int,
    unitPrice Money,
) (OrderLineItem, error) {
    if productID == "" {
        return OrderLineItem{}, errors.New("product id required")
    }
    if quantity <= 0 {
        return OrderLineItem{}, errors.New("quantity must be positive")
    }

    return OrderLineItem{
        productID:   productID,
        quantity:    quantity,
        unitPrice:   unitPrice,
        description: "", // Se llena después
    }, nil
}

// Total calcula el total de esta línea
func (oli OrderLineItem) Total() (Money, error) {
    // Multiplicar price * quantity
    // (Aquí simplificamos, en realidad usarías métodos de Money)
    totalAmount := oli.unitPrice.Amount() * int64(oli.quantity)
    return NewMoney(totalAmount, oli.unitPrice.Currency())
}

// ===== ORDER ENTITY =====

// Archivo: domain/order.go
// Paquete: domain
// Concepto: Order Entity - Orden de compra con múltiples líneas

// OrderStatus define los estados posibles de una orden
type OrderStatus string

const (
    OrderStatusPending   OrderStatus = "pending"
    OrderStatusPaid      OrderStatus = "paid"
    OrderStatusShipped   OrderStatus = "shipped"
    OrderStatusDelivered OrderStatus = "delivered"
    OrderStatusCancelled OrderStatus = "cancelled"
)

// Order es una Entity que representa una orden de compra
type Order struct {
    // IDENTIDAD (nunca cambia)
    id OrderID

    // DATOS DE NEGOCIO
    customerID  string
    items       []OrderLineItem
    status      OrderStatus
    total       Money
    notes       string

    // FECHAS
    createdAt   time.Time
    paidAt      *time.Time
    shippedAt   *time.Time
    deliveredAt *time.Time
}

// NewOrder crea una orden con al menos un item
func NewOrder(id OrderID, customerID string, firstItem OrderLineItem) (Order, error) {
    if customerID == "" {
        return Order{}, errors.New("customer id required")
    }

    return Order{
        id:        id,
        customerID: customerID,
        items:     []OrderLineItem{firstItem},
        status:    OrderStatusPending,
        createdAt: time.Now(),
    }, nil
}

// ID retorna la identidad
func (o Order) ID() OrderID {
    return o.id
}

// AddLineItem añade un producto a la orden si está en estado correcto
func (o *Order) AddLineItem(item OrderLineItem) error {
    if o.status != OrderStatusPending {
        return errors.New("can only add items to pending orders")
    }

    o.items = append(o.items, item)
    return o.recalculateTotal()
}

// RemoveLineItem remueve un producto
func (o *Order) RemoveLineItem(productID string) error {
    if o.status != OrderStatusPending {
        return errors.New("can only remove items from pending orders")
    }

    for i, item := range o.items {
        if item.productID == productID {
            o.items = append(o.items[:i], o.items[i+1:]...)
            return o.recalculateTotal()
        }
    }

    return errors.New("item not found")
}

// MarkAsPaid transiciona de pending a paid
func (o *Order) MarkAsPaid() error {
    if o.status != OrderStatusPending {
        return errors.New("can only mark pending orders as paid")
    }

    now := time.Now()
    o.status = OrderStatusPaid
    o.paidAt = &now
    return nil
}

// Ship transiciona a shipped
func (o *Order) Ship() error {
    if o.status != OrderStatusPaid {
        return errors.New("can only ship paid orders")
    }

    now := time.Now()
    o.status = OrderStatusShipped
    o.shippedAt = &now
    return nil
}

// Cancel cancela la orden
func (o *Order) Cancel() error {
    validStatuses := map[OrderStatus]bool{
        OrderStatusPending:   true,
        OrderStatusPaid:      true,
    }

    if !validStatuses[o.status] {
        return errors.New("cannot cancel order in current status")
    }

    o.status = OrderStatusCancelled
    return nil
}

// ItemCount retorna cantidad de ítems
func (o Order) ItemCount() int {
    return len(o.items)
}

// Total retorna el total de la orden
func (o Order) Total() Money {
    return o.total
}

// recalculateTotal es un método privado que recalcula el total
func (o *Order) recalculateTotal() error {
    if len(o.items) == 0 {
        o.total, _ = NewMoney(0, "USD")
        return nil
    }

    totalAmount := int64(0)
    currency := ""

    for i, item := range o.items {
        itemTotal, _ := item.Total()
        totalAmount += itemTotal.Amount()

        if i == 0 {
            currency = itemTotal.Currency()
        }
    }

    total, _ := NewMoney(totalAmount, currency)
    o.total = total
    return nil
}

Identidad de Entity: Más Allá del ID

Una Entity se identifica por su ID, no por sus valores:

func TestEntityIdentity(t *testing.T) {
    // Dos usuarios con el mismo ID pero datos diferentes
    user1, _ := NewUser(UserID{value: "1"}, email1, "John")
    user2, _ := NewUser(UserID{value: "1"}, email2, "Jane")

    // Por Value Object, son diferentes
    if user1.Email().Equals(user2.Email()) {
        t.Error("emails should be different")
    }

    // Pero como Entities, son LA MISMA (mismo ID)
    if !user1.EntityEquals(user2) {
        t.Error("entities with same ID should be equal")
    }

    // Esto es lo opuesto a Value Objects
}

Ciclo de Vida de una Entity

Las Entities tienen transiciones de estado válidas. Esto se captura en State Machines:

        ┌─────────────────────────────────────────┐
        │         ORDER STATE MACHINE             │
        └─────────────────────────────────────────┘

        ┌──────────┐
        │ PENDING  │  ◄──── Created
        └────┬─────┘

             ├─ MarkAsPaid() ──► ┌──────────┐
             │                   │   PAID   │  ◄──── Paid
             │                   └────┬─────┘
             │                        │
             │                        ├─ Ship() ──► ┌──────────┐
             │                        │             │ SHIPPED  │
             │                        │             └────┬─────┘
             │                        │                  │
             │                        │                  ├─ Deliver() ──► ┌───────────┐
             │                        │                  │                │ DELIVERED │
             │                        │                  │                └───────────┘
             │                        │
             │                        └─ Cancel() ──┐
             │                                       ▼
             └────────────────────────────────► ┌──────────────┐
                                                │  CANCELLED   │
                                                └──────────────┘

En código:

// ValidTransition verifica si una transición es válida
func (o Order) ValidTransition(newStatus OrderStatus) bool {
    transitions := map[OrderStatus][]OrderStatus{
        OrderStatusPending: {OrderStatusPaid, OrderStatusCancelled},
        OrderStatusPaid:    {OrderStatusShipped, OrderStatusCancelled},
        OrderStatusShipped: {OrderStatusDelivered},
        OrderStatusDelivered: {},
        OrderStatusCancelled: {},
    }

    valid := transitions[o.status]
    for _, status := range valid {
        if status == newStatus {
            return true
        }
    }
    return false
}

Generics para Entities (Go 1.25)

Go 1.25 permite crear “base” entities genéricas:

package domain

import "time"

// EntityBase es una base genérica para todas las entities
type EntityBase[ID comparable] struct {
    id        ID
    createdAt time.Time
    updatedAt time.Time
}

// ID retorna la identidad
func (e EntityBase[ID]) ID() ID {
    return e.id
}

// UpdatedAt retorna la última actualización
func (e EntityBase[ID]) UpdatedAt() time.Time {
    return e.updatedAt
}

// MarkAsUpdated actualiza el timestamp
func (e *EntityBase[ID]) MarkAsUpdated() {
    e.updatedAt = time.Now()
}

// Ejemplo de uso
type Product struct {
    EntityBase[string]
    name  string
    price Money
}

func (p *Product) UpdatePrice(newPrice Money) {
    p.price = newPrice
    p.MarkAsUpdated()
}

Antipatrón 1: Tratar Entities como Value Objects

// ❌ MALO
type BadUser struct {
    ID    string
    Email string
}

func (u BadUser) Equals(other BadUser) bool {
    // Compara TODO, no solo ID
    return u.ID == other.ID && u.Email == other.Email
}

// Problema: Cambiar email hace que sean "diferentes"
user1 := BadUser{ID: "1", Email: "john@example.com"}
user2 := user1
user2.Email = "jane@example.com"
// Ahora Equals dice que son diferentes, pero es la MISMA USER

// ✓ BIEN
type GoodUser struct {
    id    string
    email string
}

func (u GoodUser) Equals(other GoodUser) bool {
    // Solo compara ID
    return u.id == other.id
}

// El cambio de email no afecta la identidad
user1 := GoodUser{id: "1", email: "john@example.com"}
user2 := user1
// user2.changeEmail() -> user2 sigue siendo la misma por ID

Antipatrón 2: Entity sin Validación de Transiciones

// ❌ MALO: Estados sin validación
type BadOrder struct {
    Status string // "pending", "shipped", etc.
}

order := BadOrder{Status: "pending"}
order.Status = "delivered" // Saltó "shipped" silenciosamente

// ✓ BIEN: Métodos que validan transiciones
type GoodOrder struct {
    status OrderStatus
}

func (o *GoodOrder) Ship() error {
    if o.status != OrderStatusPaid {
        return errors.New("can only ship paid orders")
    }
    o.status = OrderStatusShipped
    return nil
}

// Fuerza el flujo correcto
order := GoodOrder{status: OrderStatusPending}
if err := order.Ship(); err != nil {
    // Error: no pagada
}

Antipatrón 3: Mucha Lógica Fuera de la Entity

// ❌ MALO: Lógica esparcida
type BadOrder struct {
    Status string
    Items  []Item
}

func TransitionOrderStatus(order *BadOrder, newStatus string) error {
    // Validación esparcida en funciones sueltas
    if order.Status == "pending" && newStatus == "shipped" {
        return errors.New("must be paid first")
    }
    order.Status = newStatus
    return nil
}

// ✓ BIEN: Lógica dentro de la Entity
type GoodOrder struct {
    status OrderStatus
}

func (o *GoodOrder) Ship() error {
    // Validación en el objeto mismo
    if o.status != OrderStatusPaid {
        return errors.New("must be paid first")
    }
    o.status = OrderStatusShipped
    return nil
}

Testing Entities

package domain

import (
    "testing"
)

func TestUserCreation(t *testing.T) {
    id, _ := NewUserID("user-1")
    email, _ := NewEmail("john@example.com")

    user, err := NewUser(id, email, "John Doe")
    if err != nil {
        t.Fatalf("unexpected error: %v", err)
    }

    if !user.ID().Equals(id) {
        t.Error("ID should match")
    }
}

func TestUserSuspension(t *testing.T) {
    id, _ := NewUserID("user-1")
    email, _ := NewEmail("john@example.com")
    user, _ := NewUser(id, email, "John")

    if err := user.Suspend("abuse"); err != nil {
        t.Fatalf("unexpected error: %v", err)
    }

    if !user.IsActive() {
        t.Error("user should not be active after suspension")
    }

    if err := user.Suspend("spam"); err == nil {
        t.Error("should not suspend already suspended user")
    }
}

func TestOrderTransitions(t *testing.T) {
    orderID, _ := NewOrderID("order-1")
    order, _ := NewOrder(orderID, "customer-1", lineItem)

    // Intenta enviar sin pagar
    if err := order.Ship(); err == nil {
        t.Error("should not allow shipping pending order")
    }

    // Paga la orden
    if err := order.MarkAsPaid(); err != nil {
        t.Fatalf("unexpected error: %v", err)
    }

    // Ahora sí puede enviar
    if err := order.Ship(); err != nil {
        t.Fatalf("unexpected error: %v", err)
    }
}

Resumen de Parte 3

  • Entities tienen identidad única que nunca cambia
  • Pueden mutar pero de forma controlada vía métodos
  • Comparación se hace por ID, no por valores
  • Tienen ciclos de vida con transiciones válidas
  • Encapsulan validaciones de cambio de estado
  • Go 1.25 permite Entities genéricas con bases compartidas

Próximos Pasos

Ya tienes Value Objects (sin identidad) y Entities (con identidad). Ahora el siguiente nivel: Aggregates. Un Aggregate es un cluster de Entities y Value Objects que se tratan como una unidad atómica.


Parte 4: Aggregates - Fronteras de Consistencia {#aggregates}

La Idea Central

Un Aggregate es lo más importante de DDD. Es donde la teoría se convierte en código práctico.

Definición:

Un Aggregate es un cluster de Entities y Value Objects tratados como una unidad atómica. Acceso externo solo a través de la Aggregate Root.

Analogía:

Imagina un carro de compras en e-commerce:

  • El carrito en sí es una Entity (tiene ID único)
  • Los artículos dentro son Value Objects o Entities anidadas
  • El total es calculado automáticamente
  • No puedes acceder a un artículo directamente; solo puedes pedirle al carrito que lo agregue o lo elimine
// ❌ MALO: Sin Aggregate
type ShoppingCart struct {
    ID    string
    Items []CartItem
}

// Alguien podría hacer:
cart.Items[0].Price = 0 // ¡¡Cambió el precio directamente!!

// ✓ BIEN: Con Aggregate
type ShoppingCart struct {
    id    string
    items []CartItem // privado
}

// Acceso controlado a través de métodos
func (sc *ShoppingCart) AddItem(item CartItem) error {
    // Validaciones internas
    // ...
    sc.items = append(sc.items, item)
}

func (sc *ShoppingCart) RemoveItem(productID string) error {
    // Validaciones internas
    // ...
}

El Concepto de Aggregate Root

El Aggregate Root es el punto de entrada al agregado. Solo el Root puede ser accedido directamente:

┌─────────────────────────────────────────┐
│          AGGREGATE: SHOPPING CART       │
├─────────────────────────────────────────┤
│                                          │
│  ┌──────────────────────────────────┐  │
│  │ AGGREGATE ROOT: ShoppingCart     │  │
│  │ - ID: cart-123                   │  │
│  │ - CustomerID: customer-456       │  │
│  └──────────────────────────────────┘  │
│           │                              │
│           ├─► Items (dentro del AG)     │
│           │   ├─ ProductID              │
│           │   ├─ Quantity               │
│           │   └─ Price                  │
│           │                              │
│           └─► Discounts                 │
│               ├─ Code                   │
│               └─ Amount                 │
│                                          │
│  ⚠️ ACCESO EXTERNO SOLO A TRAVÉS       │
│     DEL AGGREGATE ROOT                  │
│                                          │
└─────────────────────────────────────────┘

Un Aggregado Completo: Shopping Cart

// Archivo: domain/shopping_cart.go
// Paquete: domain
// Concepto: ShoppingCart Aggregate Root

package domain

import (
    "errors"
    "fmt"
)

// CartID es la identidad del carrito
type CartID struct {
    value string
}

func NewCartID(value string) (CartID, error) {
    if value == "" {
        return CartID{}, errors.New("cart id cannot be empty")
    }
    return CartID{value: value}, nil
}

func (cid CartID) String() string {
    return cid.value
}

// CartItem es un Value Object dentro del agregado
type CartItem struct {
    productID    string
    productName  string
    quantity     int
    unitPrice    Money
    discount     Money // Descuento aplicado al item
}

func NewCartItem(
    productID string,
    productName string,
    quantity int,
    unitPrice Money,
) (CartItem, error) {
    if productID == "" {
        return CartItem{}, errors.New("product id required")
    }
    if quantity <= 0 {
        return CartItem{}, errors.New("quantity must be positive")
    }

    zero, _ := NewMoney(0, unitPrice.Currency())
    return CartItem{
        productID:   productID,
        productName: productName,
        quantity:    quantity,
        unitPrice:   unitPrice,
        discount:    zero,
    }, nil
}

// Subtotal calcula el subtotal sin descuento
func (ci CartItem) Subtotal() Money {
    total := ci.unitPrice.Amount() * int64(ci.quantity)
    result, _ := NewMoney(total, ci.unitPrice.Currency())
    return result
}

// Total calcula el total con descuento
func (ci CartItem) Total() Money {
    subtotal := ci.Subtotal()
    total := subtotal.Amount() - ci.discount.Amount()
    result, _ := NewMoney(total, subtotal.Currency())
    return result
}

// ===== AGGREGATE ROOT: ShoppingCart =====

// ShoppingCart es el Aggregate Root
type ShoppingCart struct {
    // IDENTIDAD
    id ShoppingCartID

    // DATOS DEL AGREGADO
    customerID    string
    items         []CartItem // Privado: No acceso directo
    couponCode    string
    discountMoney Money

    // ESTADO
    isAbandoned   bool
    abandonedAt   *time.Time
}

// NewShoppingCart crea un carrito vacío
func NewShoppingCart(id CartID, customerID string) (ShoppingCart, error) {
    if customerID == "" {
        return ShoppingCart{}, errors.New("customer id required")
    }

    zero, _ := NewMoney(0, "USD")
    return ShoppingCart{
        id:            id,
        customerID:    customerID,
        items:         []CartItem{},
        discountMoney: zero,
    }, nil
}

// ID retorna la identidad del agregado
func (sc ShoppingCart) ID() CartID {
    return sc.id
}

// CustomerID retorna el cliente
func (sc ShoppingCart) CustomerID() string {
    return sc.customerID
}

// ItemCount retorna la cantidad de items sin exposer la slice
func (sc ShoppingCart) ItemCount() int {
    return len(sc.items)
}

// Items retorna una copia de los items (no la slice interna)
func (sc ShoppingCart) Items() []CartItem {
    // Retornar copia para evitar mutaciones externas
    itemsCopy := make([]CartItem, len(sc.items))
    copy(itemsCopy, sc.items)
    return itemsCopy
}

// Total calcula el total del carrito
func (sc ShoppingCart) Total() Money {
    if len(sc.items) == 0 {
        zero, _ := NewMoney(0, "USD")
        return zero
    }

    totalAmount := int64(0)
    currency := ""

    for i, item := range sc.items {
        totalAmount += item.Total().Amount()
        if i == 0 {
            currency = item.Total().Currency()
        }
    }

    total, _ := NewMoney(totalAmount, currency)
    return total
}

// AddItem añade un producto al carrito
// VALIDACIONES INTERNAS del Agregado
func (sc *ShoppingCart) AddItem(item CartItem) error {
    // Validación 1: Carrito no abandonado
    if sc.isAbandoned {
        return errors.New("cannot add item to abandoned cart")
    }

    // Validación 2: No duplicar productos
    for _, existing := range sc.items {
        if existing.productID == item.productID {
            return fmt.Errorf("product %s already in cart", item.productID)
        }
    }

    // Validación 3: Verificar moneda consistente
    if len(sc.items) > 0 {
        firstCurrency := sc.items[0].unitPrice.Currency()
        if item.unitPrice.Currency() != firstCurrency {
            return fmt.Errorf(
                "currency mismatch: cart is %s, item is %s",
                firstCurrency,
                item.unitPrice.Currency(),
            )
        }
    }

    sc.items = append(sc.items, item)
    return nil
}

// UpdateItemQuantity actualiza la cantidad de un artículo
func (sc *ShoppingCart) UpdateItemQuantity(productID string, newQuantity int) error {
    if newQuantity <= 0 {
        return errors.New("quantity must be positive")
    }

    for i, item := range sc.items {
        if item.productID == productID {
            sc.items[i].quantity = newQuantity
            return nil
        }
    }

    return errors.New("item not found in cart")
}

// RemoveItem elimina un producto del carrito
func (sc *ShoppingCart) RemoveItem(productID string) error {
    if sc.isAbandoned {
        return errors.New("cannot remove item from abandoned cart")
    }

    for i, item := range sc.items {
        if item.productID == productID {
            sc.items = append(sc.items[:i], sc.items[i+1:]...)
            return nil
        }
    }

    return errors.New("item not found")
}

// ApplyCoupon aplica un código de cupón
// LÓGICA COMPLEJA DENTRO DEL AGREGADO
func (sc *ShoppingCart) ApplyCoupon(couponCode string, discount Money) error {
    // Validación: Solo un cupón por carrito
    if sc.couponCode != "" {
        return errors.New("coupon already applied")
    }

    // Validación: Debe haber items
    if len(sc.items) == 0 {
        return errors.New("cannot apply coupon to empty cart")
    }

    // Validación: Descuento no puede ser mayor al total
    total := sc.Total()
    if discount.Amount() > total.Amount() {
        return errors.New("discount cannot exceed total")
    }

    sc.couponCode = couponCode
    sc.discountMoney = discount
    return nil
}

// Checkout convierte el carrito a una orden
// TRANSICIÓN DE ESTADO
func (sc *ShoppingCart) Checkout() (Order, error) {
    // Validaciones
    if len(sc.items) == 0 {
        return Order{}, errors.New("cannot checkout empty cart")
    }

    if len(sc.items) > 100 {
        return Order{}, errors.New("too many items in cart")
    }

    // Crear la orden (delegamos a otro agregado)
    orderID, _ := NewOrderID(fmt.Sprintf("order-%s", sc.id.String()))

    // Convertir items del carrito a items de orden
    firstItem, _ := NewOrderLineItem(
        sc.items[0].productID,
        sc.items[0].quantity,
        sc.items[0].unitPrice,
    )

    order, _ := NewOrder(orderID, sc.customerID, firstItem)

    return order, nil
}

// Abandon marca el carrito como abandonado
func (sc *ShoppingCart) Abandon() error {
    if sc.isAbandoned {
        return errors.New("cart already abandoned")
    }

    sc.isAbandoned = true
    now := time.Now()
    sc.abandonedAt = &now
    return nil
}

// IsEmpty verifica si el carrito está vacío
func (sc ShoppingCart) IsEmpty() bool {
    return len(sc.items) == 0
}

Bounded Context: Separación de Agregados

Los Agregados no viven solos. Viven dentro de Bounded Contexts. Un Bounded Context es una división lógica del sistema:

┌────────────────────────────────────────────────────────────┐
│                   E-COMMERCE SYSTEM                        │
├────────────────────────────────────────────────────────────┤
│                                                              │
│  ┌──────────────────┐  ┌──────────────────┐               │
│  │ SHOPPING CONTEXT │  │ ORDER CONTEXT    │               │
│  ├──────────────────┤  ├──────────────────┤               │
│  │                  │  │                  │               │
│  │ - ShoppingCart   │  │ - Order          │               │
│  │ - CartItem       │  │ - OrderItem      │               │
│  │ - Wishlist       │  │ - Payment        │               │
│  │                  │  │ - Shipment       │               │
│  └──────────────────┘  └──────────────────┘               │
│          ⬇️                      ⬇️                        │
│     "ADD TO CART"          "PLACE ORDER"                   │
│     (Evento)               (Evento)                        │
│                                                              │
│  ┌──────────────────┐  ┌──────────────────┐               │
│  │ PAYMENT CONTEXT  │  │ INVENTORY CONTEXT│               │
│  ├──────────────────┤  ├──────────────────┤               │
│  │                  │  │                  │               │
│  │ - Payment        │  │ - Stock          │               │
│  │ - Transaction    │  │ - Reservation    │               │
│  │ - Refund         │  │ - Movement       │               │
│  │                  │  │                  │               │
│  └──────────────────┘  └──────────────────┘               │
│                                                              │
└────────────────────────────────────────────────────────────┘

Consistencia Eventual Entre Agregados

Los agregados están fuertemente consistentes internamente, pero eventualmente consistentes entre ellos:

// FUERTE: Dentro del agregado ShoppingCart
func (sc *ShoppingCart) ApplyCoupon(coupon string, discount Money) error {
    // Validaciones inmediatas
    // El estado del carrito es consistente ahora
}

// EVENTUAL: Entre agregados (ShoppingCart → Order)
func (s *OrderService) ProcessCheckout(cartID CartID) (OrderID, error) {
    // 1. Carrito valida internamente
    cart := repo.GetCart(cartID)

    // 2. Se crea una orden (otro agregado)
    order := createOrder(cart)

    // 3. Se guarda la orden
    repo.SaveOrder(order)

    // 4. El carrito se marca como procesado (evento asíncrono)
    // En ese momento podría fallar, pero la orden está guardada
    events.Publish(CartCheckoutInitiated{CartID: cartID})

    return order.ID(), nil
}

El Patrón Event Sourcing en Agregados

Aunque no es obligatorio, muchos sistemas DDD usan Event Sourcing donde cada cambio es un evento:

package domain

// DomainEvent es la interfaz base para eventos
type DomainEvent interface {
    AggregateID() string
    OccurredAt() time.Time
}

// ItemAddedToCart es un evento
type ItemAddedToCart struct {
    cartID    string
    productID string
    quantity  int
    timestamp time.Time
}

func (e ItemAddedToCart) AggregateID() string {
    return e.cartID
}

func (e ItemAddedToCart) OccurredAt() time.Time {
    return e.timestamp
}

// Modificar el agregado para emitir eventos
type ShoppingCart struct {
    id     CartID
    items  []CartItem
    events []DomainEvent // Eventos pendientes
}

// AddItem ahora registra el evento
func (sc *ShoppingCart) AddItem(item CartItem) error {
    // Validaciones...

    sc.items = append(sc.items, item)

    // Registrar evento
    sc.events = append(sc.events, ItemAddedToCart{
        cartID:    sc.id.String(),
        productID: item.productID,
        quantity:  item.quantity,
        timestamp: time.Now(),
    })

    return nil
}

// GetUncommittedEvents retorna eventos y los limpia
func (sc *ShoppingCart) GetUncommittedEvents() []DomainEvent {
    events := sc.events
    sc.events = []DomainEvent{} // Limpia después de leer
    return events
}

Antipatrón 1: Agregados Demasiado Grandes

// ❌ MALO: Un agregado que hace todo
type GodAggregate struct {
    ID              string
    UserData        UserData
    OrderData       OrderData
    PaymentData     PaymentData
    InventoryData   InventoryData
    ShippingData    ShippingData
    AnalyticsData   AnalyticsData
    // ... 50 campos más
}

// Problema: Es imposible de razonar, cambios crean cascadas

// ✓ BIEN: Agregados pequeños y enfocados
type ShoppingCart struct {
    id        CartID
    items     []CartItem
}

type Order struct {
    id        OrderID
    items     []OrderItem
}

type Payment struct {
    id        PaymentID
    orderId   OrderID
    amount    Money
}

Antipatrón 2: Acceso Directo a Entidades Internas

// ❌ MALO: Exponer entidades internas
type Cart struct {
    ID    string
    Items []CartItem // Público
}

// Alguien puede hacer:
cart.Items[0].Price = 0 // ¡¡Inconsistencia!!
cart.Items = nil        // ¡¡Borró todo!!

// ✓ BIEN: Métodos controlados
type Cart struct {
    id    string
    items []CartItem // Privado
}

func (c Cart) Items() []CartItem {
    // Retorna copia
    copy := make([]CartItem, len(c.items))
    copy(copy, c.items)
    return copy
}

Antipatrón 3: Queries Directas en Agregados

// ❌ MALO: Lógica esparcida
orders := repo.FindAll()
total := 0
for _, order := range orders {
    if order.Status == "paid" && order.Amount > 1000 {
        total += order.Amount
    }
}

// ✓ BIEN: Métodos en el agregado
func (o Order) IsPaidAndLargeOrder() bool {
    return o.status == OrderStatusPaid && o.total.Amount() > 100000
}

// O incluso mejor: Usar un specification pattern
orders := repo.FindBy(LargeOrderSpecification{})

Testing Agregados

// Archivo: domain/shopping_cart_test.go
// Paquete: domain
// Responsabilidad: Tests para ShoppingCart Aggregate

package domain

import (
    "testing"
)

func TestAddingItemToCart(t *testing.T) {
    cartID, _ := NewCartID("cart-1")
    cart, _ := NewShoppingCart(cartID, "customer-1")

    item, _ := NewCartItem("prod-1", "Widget", 2, money100)

    if err := cart.AddItem(item); err != nil {
        t.Fatalf("unexpected error: %v", err)
    }

    if cart.ItemCount() != 1 {
        t.Errorf("expected 1 item, got %d", cart.ItemCount())
    }
}

func TestCannotAddDuplicateItem(t *testing.T) {
    cartID, _ := NewCartID("cart-1")
    cart, _ := NewShoppingCart(cartID, "customer-1")

    item, _ := NewCartItem("prod-1", "Widget", 2, money100)
    cart.AddItem(item)

    // Intentar agregar el mismo producto
    if err := cart.AddItem(item); err == nil {
        t.Error("should not allow duplicate items")
    }
}

func TestCheckoutWithValidCart(t *testing.T) {
    cartID, _ := NewCartID("cart-1")
    cart, _ := NewShoppingCart(cartID, "customer-1")

    item, _ := NewCartItem("prod-1", "Widget", 1, money100)
    cart.AddItem(item)

    order, err := cart.Checkout()
    if err != nil {
        t.Fatalf("unexpected error: %v", err)
    }

    if order.ID().String() == "" {
        t.Error("order should have an ID")
    }
}

func TestCannotCheckoutEmptyCart(t *testing.T) {
    cartID, _ := NewCartID("cart-1")
    cart, _ := NewShoppingCart(cartID, "customer-1")

    _, err := cart.Checkout()
    if err == nil {
        t.Error("should not checkout empty cart")
    }
}

Resumen de Parte 4

  • Aggregates son clusters de Entities y VOs
  • Acceso solo a través del Aggregate Root
  • Consistencia fuerte dentro, eventual entre agregados
  • Agregados deben ser pequeños y enfocados
  • Los eventos permiten comunicación entre agregados
  • Go 1.25 permite slices privadas con métodos que devuelven copias

Próximos Pasos

Ahora que dominas Agregados, necesitamos aprender Domain Services: Lógica que no encaja en una Entity ni en un Value Object.


Parte 5: Domain Services - Lógica Sin Hogar {#domain-services}

El Dilema de la Lógica Desplazada

Tienes un agregado Order y un agregado Inventory. Necesitas verificar que hay stock disponible antes de procesar un orden.

¿Dónde va esa lógica?

  • ¿En Order? No, Order no sabe de inventario.
  • ¿En Inventory? No, Inventory no sabe de órdenes.

Aquí es donde nacen los Domain Services. Son orquestadores de lógica de negocio entre agregados.

┌──────────────────────────────────────────┐
│        Domain Service                    │
│   (No es un Agregado)                    │
├──────────────────────────────────────────┤
│                                          │
│  CanPlaceOrder(order, inventory) → bool  │
│                                          │
│  Orquesta:                               │
│    1. Verifica orden válida              │
│    2. Verifica inventario disponible     │
│    3. Retorna resultado                  │
│                                          │
└──────────────────────────────────────────┘
     ↓                          ↓
┌───────────────┐         ┌─────────────┐
│     Order     │         │  Inventory  │
│   (Agregado)  │         │  (Agregado) │
└───────────────┘         └─────────────┘

Definición y Características

Un Domain Service es un servicio que expresa lógica de negocio que de forma natural no encaja en un Entity o Value Object.

Características:

  1. Stateless: No mantiene estado
  2. Enfocado: Hace una cosa bien
  3. Nombrado en el lenguaje del negocio: “OrderProcessor”, no “OrderUtil”
  4. Recibe y retorna Value Objects o Agregados
  5. Define contratos claros: interfaces públicas

Ejemplo 1: Servicio de Transferencia Bancaria

// Archivo: domain/transfer_service.go
// Paquete: domain
// Concepto: TransferService Domain Service - Orquesta transferencias

package domain

import "errors"

// TransferService orquesta transferencias entre cuentas
// Nota: NO es un Agregado, es un Domain Service
type TransferService struct {
    accountRepo AccountRepository // Inyectado
}

// NewTransferService crea el servicio
func NewTransferService(repo AccountRepository) *TransferService {
    return &TransferService{
        accountRepo: repo,
    }
}

// Transfer realiza una transferencia entre dos cuentas
// Este es el contrato del servicio
func (ts *TransferService) Transfer(
    fromAccountID string,
    toAccountID string,
    amount Money,
) error {
    // Paso 1: Obtener ambas cuentas
    fromAccount, err := ts.accountRepo.FindByID(fromAccountID)
    if err != nil {
        return errors.New("source account not found")
    }

    toAccount, err := ts.accountRepo.FindByID(toAccountID)
    if err != nil {
        return errors.New("destination account not found")
    }

    // Paso 2: Validar precondiciones
    if !fromAccount.CanTransfer() {
        return errors.New("source account cannot transfer (frozen or closed)")
    }

    if !toAccount.CanReceiveTransfer() {
        return errors.New("destination account cannot receive transfer")
    }

    // Paso 3: Validar fondos suficientes (DENTRO del agregado)
    if err := fromAccount.Withdraw(amount); err != nil {
        return err
    }

    // Paso 4: Realizar depósito (DENTRO del agregado)
    if err := toAccount.Deposit(amount); err != nil {
        // ROLLBACK si falla
        fromAccount.Deposit(amount) // Reversión
        return errors.New("deposit failed, transfer rolled back")
    }

    // Paso 5: Persistir cambios
    if err := ts.accountRepo.Save(fromAccount); err != nil {
        // Aquí sería Saga pattern si realmente es distribuido
        return errors.New("failed to save source account")
    }

    if err := ts.accountRepo.Save(toAccount); err != nil {
        // Recuperar el estado anterior (Saga pattern)
        return errors.New("failed to save destination account")
    }

    // Paso 6: Emitir eventos de dominio
    // ts.eventBus.Publish(TransferCompleted{...})

    return nil
}

Ejemplo 2: Servicio de Descuentos Complejos

package domain

import "sort"

// DiscountService calcula descuentos basados en reglas complejas
type DiscountService struct {
    // Podría inyectar repositorio de reglas
}

// CalculateDiscount aplica lógica de negocio compleja
func (ds *DiscountService) CalculateDiscount(
    order Order,
    customer Customer,
    date time.Time,
) (Money, error) {
    discount, _ := NewMoney(0, "USD")

    // Regla 1: Black Friday
    if isBlackFriday(date) {
        blackFridayDiscount := order.Total().Amount() / 5 // 20%
        discount, _ = NewMoney(blackFridayDiscount, "USD")
    }

    // Regla 2: Cliente VIP
    if customer.IsVIP() {
        vipDiscount := order.Total().Amount() / 10 // 10%
        if vipDiscount > discount.Amount() {
            discount, _ = NewMoney(vipDiscount, "USD")
        }
    }

    // Regla 3: Volumen
    if order.ItemCount() > 10 {
        volumeDiscount := order.Total().Amount() / 20 // 5%
        discount, _ = NewMoney(volumeDiscount, "USD")
    }

    // Regla 4: Primer compra
    if customer.PurchaseCount() == 0 {
        firstBuyDiscount, _ := NewMoney(1000, "USD") // $10 fijo
        if firstBuyDiscount.Amount() > discount.Amount() {
            discount = firstBuyDiscount
        }
    }

    // Validación: El descuento nunca puede ser > 50% del total
    maxDiscount := order.Total().Amount() / 2
    if discount.Amount() > maxDiscount {
        discount, _ = NewMoney(maxDiscount, "USD")
    }

    return discount, nil
}

Antipatrón 1: Usar Domain Services para TODO

// ❌ MALO: Un Domain Service que hace demasiado
type UserService struct {
    repo UserRepository
}

func (us *UserService) CreateAndNotifyAndAuditAndUpdateStats(
    email string,
    name string,
) error {
    // 1. Crear usuario
    // 2. Enviar email
    // 3. Auditar en BD
    // 4. Actualizar estadísticas
    // 5. Invalidar caché
    // 6. ...
}

// Problema: Responsabilidades mezcladas, difícil de testear

// ✓ BIEN: Servicios pequeños y enfocados
type CreateUserService struct {
    userRepo    UserRepository
    emailSender EmailSender // Inyectado
}

func (cs *CreateUserService) Execute(email string, name string) (UserID, error) {
    // Crear usuario
    user, err := NewUser(/* ... */)
    if err != nil {
        return UserID{}, err
    }

    // Guardar
    cs.userRepo.Save(user)

    // Emitir evento
    // El event bus se encargará de notificaciones, auditoría, etc.
    return user.ID(), nil
}

Antipatrón 2: Domain Service Stateful

// ❌ MALO: Un Domain Service que mantiene estado
type BadOrderService struct {
    lastOrderID OrderID        // Estado
    cache       map[string]int // Estado
}

func (bos *BadOrderService) CreateOrder(items []Item) OrderID {
    // Usa estado interno
    // Difícil de paralelizar, testing complicado
}

// ✓ BIEN: Domain Service stateless
type GoodOrderService struct {
    // SIN estado
}

func (gos *GoodOrderService) CreateOrder(items []Item) (OrderID, error) {
    // Genera ID nuevo (sin depender de estado)
    id := generateUUID()
    // ...
}

// Puede ser usado concurrentemente sin problemas

Antipatrón 3: Domain Service que Viola Responsabilidades

// ❌ MALO: Mezclar lógica de negocio con infraestructura
type BadPaymentService struct {
    db *sql.DB
}

func (bps *BadPaymentService) ProcessPayment(payment *Payment) error {
    // Validar en Go
    if payment.Amount.Amount() <= 0 {
        return errors.New("invalid amount")
    }

    // Escribir directamente en BD (infraestructura)
    query := "INSERT INTO payments ..."
    bps.db.Exec(query)

    // Hacer HTTP request (infraestructura)
    resp := http.Post("https://gateway.com/pay", ...)

    // Todo mezclado, difícil de testear
}

// ✓ BIEN: Separar responsabilidades
type GoodPaymentService struct {
    repo             PaymentRepository // Puerto
    paymentGateway   PaymentGateway    // Puerto
}

func (gps *GoodPaymentService) ProcessPayment(payment *Payment) error {
    // Validar (lógica de negocio)
    if err := payment.Validate(); err != nil {
        return err
    }

    // Procesar (a través de puertos)
    if err := gps.paymentGateway.Charge(payment); err != nil {
        return err
    }

    // Persistir (a través del puerto)
    gps.repo.Save(payment)

    return nil
}

Diferencia: Domain Service vs Application Service

Este es un punto crucial que muchos confunden:

AspectoDomain ServiceApplication Service
ResponsabilidadLógica de negocioOrquestación
AccesoRepositoriosRepositorios + APIs externas
TransaccionesNegociosTécnicas
TestabilidadFácil (solo mocks)Requiere contexto
Ubicacióndomain/application/
EjemploTransferServiceCreateOrderUseCase
// DOMAIN SERVICE: Lógica de negocio pura
type PricingService struct {
    // Sin inyecciones técnicas
}

func (ps *PricingService) CalculateOrderPrice(
    items []OrderItem,
    customer Customer,
) (Money, error) {
    // Lógica 100% negocio
    // Sin SQL, sin HTTP, sin JSON
    total, _ := NewMoney(0, "USD")
    for _, item := range items {
        total, _ = total.Add(item.Total())
    }
    return total, nil
}

// APPLICATION SERVICE: Orquestación
type CreateOrderUseCase struct {
    orderRepo    OrderRepository
    paymentAPI   PaymentGateway
    pricing      PricingService // Inyectar Domain Service
}

func (cou *CreateOrderUseCase) Execute(req CreateOrderRequest) (OrderID, error) {
    // Convertir request a dominio
    items := convertToDomain(req.Items)
    customer, _ := cou.orderRepo.FindCustomer(req.CustomerID)

    // Usar Domain Service
    total, _ := cou.pricing.CalculateOrderPrice(items, customer)

    // Llamar infra
    order := createOrder(items, total)
    cou.orderRepo.Save(order)

    // Retornar respuesta
    return order.ID(), nil
}

Domain Services y Transacciones

Una pregunta común: ¿Las transacciones van en Domain Services o Application Services?

Respuesta: Ambas, pero en diferentes niveles.

// DOMAIN LEVEL: Lógica de negocio transaccional
// (El Domain Service valida que sea posible)
func (ts *TransferService) Transfer(
    from AccountID,
    to AccountID,
    amount Money,
) error {
    // Validaciones de negocio
    if err := ts.validateTransfer(from, to, amount); err != nil {
        return err
    }

    // Cambios de estado (potencialmente transaccionales)
    // Pero sin detalles técnicos
    return nil
}

// APPLICATION LEVEL: Transacciones técnicas
// (El Application Service maneja la transacción DB)
func (cou *CreateOrderUseCase) Execute(req CreateOrderRequest) (OrderID, error) {
    // Inicia transacción técnica
    tx := cou.db.BeginTx()
    defer tx.Rollback()

    // Usa Domain Services dentro de la transacción
    err := cou.transferService.Transfer(accountA, accountB, amount)
    if err != nil {
        return OrderID{}, err
    }

    // Si todo bien, commit
    tx.Commit()
    return orderID, nil
}

Domain Services en Go 1.25

Go 1.25 permite usar iteradores para Domain Services sofisticados:

package domain

import "iter"

// OrderProcessingService usa iteradores para procesamiento declarativo
type OrderProcessingService struct {
    discountService *DiscountService
}

// ProcessOrdersInBulk procesa múltiples órdenes
func (ops *OrderProcessingService) ProcessOrdersInBulk(
    orders iter.Seq[Order],
) iter.Seq[ProcessedOrder] {
    return func(yield func(ProcessedOrder) bool) {
        for order := range orders {
            // Aplicar lógica de negocio a cada orden
            processed := ProcessedOrder{
                Order: order,
                // ... lógica aquí
            }

            if !yield(processed) {
                return
            }
        }
    }
}

// Uso
func Example() {
    service := NewOrderProcessingService(/* ... */)

    // Procesar un stream de órdenes
    for processedOrder := range service.ProcessOrdersInBulk(orderSeq) {
        fmt.Println(processedOrder)
    }
}

Testing Domain Services

Domain Services son fáciles de testear porque dependencias están inyectadas:

// Archivo: domain/transfer_service_test.go
// Paquete: domain
// Responsabilidad: Tests para TransferService

package domain

import "testing"

// Mock simple del repositorio
type MockAccountRepo struct {
    accounts map[string]*Account
}

func (mar *MockAccountRepo) FindByID(id string) (*Account, error) {
    acc, ok := mar.accounts[id]
    if !ok {
        return nil, errors.New("not found")
    }
    return acc, nil
}

func (mar *MockAccountRepo) Save(acc *Account) error {
    mar.accounts[acc.ID().String()] = acc
    return nil
}

// Test
func TestTransferService_Success(t *testing.T) {
    // Setup
    fromAccount, _ := NewBankAccount(/* ... */)
    toAccount, _ := NewBankAccount(/* ... */)

    mockRepo := &MockAccountRepo{
        accounts: map[string]*Account{
            "from": &fromAccount,
            "to":   &toAccount,
        },
    }

    service := NewTransferService(mockRepo)

    // Execute
    amount, _ := NewMoney(5000, "USD")
    err := service.Transfer("from", "to", amount)

    // Assert
    if err != nil {
        t.Fatalf("unexpected error: %v", err)
    }

    // Verificar cambios
    from, _ := mockRepo.FindByID("from")
    if from.Balance().Amount() != 95000 {
        t.Errorf("wrong balance: %d", from.Balance().Amount())
    }
}

func TestTransferService_InsufficientFunds(t *testing.T) {
    // Setup igual
    // ...

    service := NewTransferService(mockRepo)

    amount, _ := NewMoney(150000, "USD") // Más que el saldo
    err := service.Transfer("from", "to", amount)

    if err == nil {
        t.Error("should fail with insufficient funds")
    }
}

Resumen de Parte 5

  • Domain Services orquestan lógica entre Agregados
  • Son stateless y enfocados
  • NO reemplazan Entities/Value Objects
  • Diferentes de Application Services (que son de infraestructura)
  • Fáciles de testear porque sus dependencias se inyectan
  • Go 1.25 permite iteradores para servicios sofisticados

Próximos Pasos

Ahora tenemos todos los bloques de DDD puro: Value Objects, Entities, Aggregates, y Domain Services.

Llegó el momento de agregar la arquitectura: Ports & Adapters (Hexagonal). Aquí es donde conectamos el dominio limpio con el mundo externo.


Parte 6: Ports & Adapters - Arquitectura Hexagonal {#ports-adapters}

La Idea Central de Hexagonal

La Arquitectura Hexagonal (también llamada Ports & Adapters) es una estructura que aisla el dominio del mundo externo.

El dominio está en el centro, y todo lo demás (BD, APIs, UI) está en el exterior, accesible solo a través de puertos.

        ┌─────────────────────────────────────────────────────┐
        │                 HEXAGONAL ARCHITECTURE              │
        ├─────────────────────────────────────────────────────┤
        │                                                     │
        │  ┌──────────────────────────────────────────┐      │
        │  │         DOMAIN LAYER (PURO)             │      │
        │  │                                          │      │
        │  │  - Value Objects                         │      │
        │  │  - Entities                              │      │
        │  │  - Aggregates                            │      │
        │  │  - Domain Services                       │      │
        │  │                                          │      │
        │  └──────────────────────────────────────────┘      │
        │           ↑              ↑              ↑           │
        │           │              │              │           │
        │    ┌──────┴──────┬───────┴───┬──────────┴─────┐    │
        │    │             │           │                │    │
        │  PORT 1        PORT 2      PORT 3           PORT 4 │
        │ (Repository)  (Service)   (Event Bus)  (Payment)   │
        │    │             │           │                │    │
        │    ├──────┴──────┼───────┴───┼──────────┴─────┤    │
        │                                                     │
        ├─────────────────────────────────────────────────────┤
        │              ADAPTER LAYER                          │
        │                                                     │
        │  ┌─────────────┐  ┌──────────┐  ┌──────────┐       │
        │  │ PostgreSQL  │  │ RabbitMQ │  │ Stripe   │       │
        │  │  Adapter    │  │ Adapter  │  │ Adapter  │       │
        │  └─────────────┘  └──────────┘  └──────────┘       │
        │                                                     │
        └─────────────────────────────────────────────────────┘

Puertos (Interfaces)

Un Puerto es una interfaz Go que define el contrato entre el dominio y el exterior.

package domain

// ===== PUERTOS (Interfaces que define el dominio) =====

// AccountRepository es un puerto para acceder a cuentas
type AccountRepository interface {
    FindByID(id string) (*Account, error)
    FindByEmail(email Email) (*Account, error)
    Save(account *Account) error
    Delete(id string) error
}

// PaymentGateway es un puerto para procesar pagos
type PaymentGateway interface {
    Charge(paymentID string, amount Money) error
    Refund(paymentID string, amount Money) error
    CheckStatus(paymentID string) (PaymentStatus, error)
}

// EventPublisher es un puerto para publicar eventos
type EventPublisher interface {
    Publish(event DomainEvent) error
}

// NotificationService es un puerto para enviar notificaciones
type NotificationService interface {
    SendEmail(email Email, subject string, body string) error
    SendSMS(phone string, message string) error
}

// ===== NOTA: El dominio NO SABE cómo se implementan =====
// Solo define qué necesita

Adaptadores (Implementaciones)

Un Adaptador es una implementación concreta de un Puerto.

package infrastructure

import (
    "database/sql"
    "domain"
)

// PostgresAccountRepository es un adaptador que usa PostgreSQL
type PostgresAccountRepository struct {
    db *sql.DB
}

// NewPostgresAccountRepository crea el adaptador
func NewPostgresAccountRepository(db *sql.DB) *PostgresAccountRepository {
    return &PostgresAccountRepository{db: db}
}

// FindByID implementa AccountRepository
func (par *PostgresAccountRepository) FindByID(id string) (*domain.Account, error) {
    row := par.db.QueryRow("SELECT id, email, balance FROM accounts WHERE id = $1", id)

    var accountID, email string
    var balance int64

    if err := row.Scan(&accountID, &email, &balance); err != nil {
        return nil, err
    }

    // Convertir datos crudos a dominio
    acc, err := domain.NewAccount(accountID, email, balance)
    if err != nil {
        return nil, err
    }

    return &acc, nil
}

// Save implementa AccountRepository
func (par *PostgresAccountRepository) Save(account *domain.Account) error {
    query := `
        INSERT INTO accounts (id, email, balance) VALUES ($1, $2, $3)
        ON CONFLICT (id) DO UPDATE SET balance = $3
    `

    _, err := par.db.Exec(query, account.ID(), account.Email(), account.Balance())
    return err
}

// Delete implementa AccountRepository
func (par *PostgresAccountRepository) Delete(id string) error {
    _, err := par.db.Exec("DELETE FROM accounts WHERE id = $1", id)
    return err
}

// ===== OTRO ADAPTADOR: Para testing =====

type InMemoryAccountRepository struct {
    accounts map[string]*domain.Account
}

func NewInMemoryAccountRepository() *InMemoryAccountRepository {
    return &InMemoryAccountRepository{
        accounts: make(map[string]*domain.Account),
    }
}

func (iar *InMemoryAccountRepository) FindByID(id string) (*domain.Account, error) {
    acc, ok := iar.accounts[id]
    if !ok {
        return nil, errors.New("not found")
    }
    return acc, nil
}

func (iar *InMemoryAccountRepository) Save(account *domain.Account) error {
    iar.accounts[account.ID()] = account
    return nil
}

// El dominio puede usar cualquiera sin cambios

Inyección de Dependencias en Go 1.25

Go 1.25 hace DI limpio sin frameworks. Simplemente pasas las dependencias:

// Archivo: application/create_account_use_case.go
// Paquete: application
// Concepto: CreateAccountUseCase - Inyección de dependencias

package application

type CreateAccountUseCase struct {
    // Puertos inyectados
    accountRepo domain.AccountRepository
    eventBus    domain.EventPublisher
    emailService domain.NotificationService
}

// NewCreateAccountUseCase inyecta dependencias
func NewCreateAccountUseCase(
    repo domain.AccountRepository,
    eventBus domain.EventPublisher,
    emailService domain.NotificationService,
) *CreateAccountUseCase {
    return &CreateAccountUseCase{
        accountRepo: repo,
        eventBus:    eventBus,
        emailService: emailService,
    }
}

// Execute es el caso de uso
func (cau *CreateAccountUseCase) Execute(
    email domain.Email,
    initialBalance domain.Money,
) (domain.AccountID, error) {
    // Usar puertos sin saber la implementación
    account, err := domain.NewAccount(email, initialBalance)
    if err != nil {
        return domain.AccountID{}, err
    }

    // Guardar (a través del puerto)
    if err := cau.accountRepo.Save(account); err != nil {
        return domain.AccountID{}, err
    }

    // Publicar evento (a través del puerto)
    cau.eventBus.Publish(AccountCreated{AccountID: account.ID()})

    // Notificar (a través del puerto)
    cau.emailService.SendEmail(email, "Welcome", "Your account is ready")

    return account.ID(), nil
}

// ===== EN main.go =====

// Archivo: cmd/main.go
// Responsabilidad: Composición de dependencias y entry point

func main() {
    // Crear adaptadores concretos
    db := createPostgresConnection()
    accountRepo := infrastructure.NewPostgresAccountRepository(db)
    eventBus := infrastructure.NewKafkaEventBus()
    emailService := infrastructure.NewGmailNotificationService()

    // Inyectar en el caso de uso
    useCase := application.NewCreateAccountUseCase(
        accountRepo,
        eventBus,
        emailService,
    )

    // Usar
    accountID, err := useCase.Execute(email, balance)
}

Estructura de Proyecto Hexagonal

myapp/
├── cmd/
│   └── main.go                    # Entry point

├── domain/                         # ❤️ CORAZÓN PURO
│   ├── account.go                 # Entities
│   ├── money.go                   # Value Objects
│   ├── repositories.go            # Interfaces (Puertos)
│   ├── services.go                # Domain Services
│   └── events.go                  # Domain Events

├── application/                    # Use Cases
│   ├── create_account.go
│   ├── transfer_money.go
│   └── checkout.go

├── infrastructure/                 # Adaptadores
│   ├── postgres/
│   │   └── account_repository.go  # PostgreSQL adapter
│   ├── stripe/
│   │   └── payment_adapter.go     # Stripe adapter
│   ├── kafka/
│   │   └── event_publisher.go     # Kafka adapter
│   └── smtp/
│       └── email_service.go       # Email adapter

├── ports/                          # Re-export de interfaces
│   └── interfaces.go

└── go.mod

Adapter para Payment Gateway (Ejemplo Real)

// Archivo: infrastructure/stripe/payment_adapter.go
// Paquete: infrastructure.stripe
// Concepto: StripePaymentAdapter - Implementación de PaymentGateway

package infrastructure

import (
    "domain"
    "external-api/stripe"
)

// StripePaymentAdapter es un adaptador del gateway Stripe
type StripePaymentAdapter struct {
    apiKey string
    client *stripe.Client
}

func NewStripePaymentAdapter(apiKey string) *StripePaymentAdapter {
    return &StripePaymentAdapter{
        apiKey: apiKey,
        client: stripe.NewClient(apiKey),
    }
}

// Charge implementa domain.PaymentGateway
func (spa *StripePaymentAdapter) Charge(
    paymentID string,
    amount domain.Money,
) error {
    // Convertir Money a centavos (Stripe usa centavos)
    amountCents := amount.Amount() // Ya está en centavos

    // Llamar API de Stripe
    charge, err := spa.client.Charges.New(&stripe.ChargeParams{
        Amount:   stripe.Int64(amountCents),
        Currency: stripe.String(amount.Currency()),
        Source:   stripe.String(paymentID),
    })

    if err != nil {
        return err
    }

    if !charge.Paid {
        return errors.New("payment not completed")
    }

    return nil
}

// Refund implementa domain.PaymentGateway
func (spa *StripePaymentAdapter) Refund(
    paymentID string,
    amount domain.Money,
) error {
    // Llamar API de Stripe para reembolso
    _, err := spa.client.Refunds.New(&stripe.RefundParams{
        Charge: stripe.String(paymentID),
        Amount: stripe.Int64(amount.Amount()),
    })
    return err
}

// CheckStatus implementa domain.PaymentGateway
func (spa *StripePaymentAdapter) CheckStatus(
    paymentID string,
) (domain.PaymentStatus, error) {
    // Consultar Stripe
    charge, err := spa.client.Charges.Get(paymentID, nil)
    if err != nil {
        return domain.PaymentStatusUnknown, err
    }

    if charge.Paid {
        return domain.PaymentStatusSucceeded, nil
    }
    return domain.PaymentStatusFailed, nil
}

Antipatrón 1: Dejar que Adaptadores Contaminen el Dominio

// ❌ MALO: El dominio conoce adaptadores
package domain

import "database/sql"

type Account struct {
    id  string
    db  *sql.DB  // ¡¡Infraestructura en el dominio!!
}

func (a *Account) Save() error {
    // El dominio sabe de SQL
    query := "UPDATE accounts SET ..."
    return a.db.Exec(query)
}

// ✓ BIEN: El dominio es puro
package domain

type Account struct {
    id    string
    email Email
}

// El adaptador se encarga de persistir
package infrastructure

func (par *PostgresAccountRepository) Save(account *domain.Account) error {
    query := "UPDATE accounts SET ..."
    return par.db.Exec(query)
}

Antipatrón 2: Adaptador que Expone Detalles de Infraestructura

// ❌ MALO: El adaptador expone SQL errors
func (par *PostgresAccountRepository) FindByID(id string) (*Account, error) {
    // Retorna error de BD directo
    return nil, pq.Error{Code: "23503", Message: "Foreign key violation"}
}

// Usuario debe conocer Postgres para entender el error

// ✓ BIEN: El adaptador traduce a errores de dominio
func (par *PostgresAccountRepository) FindByID(id string) (*Account, error) {
    acc, err := scanAccount(row)
    if err != nil {
        if err == sql.ErrNoRows {
            return nil, errors.New("account not found") // Lenguaje del dominio
        }
        return nil, errors.New("database error")
    }
    return acc, nil
}

Antipatrón 3: Puertos Muy Grandes

// ❌ MALO: Un puerto que hace demasiado
type UserService interface {
    FindUser(id string) (*User, error)
    SaveUser(user *User) error
    DeleteUser(id string) error
    SendEmail(user *User, subject string) error
    LogActivity(userID string, activity string) error
    CacheUser(user *User) error
    InvalidateCache(userID string) error
    // ... 20 métodos más
}

// ✓ BIEN: Puertos pequeños y enfocados
type UserRepository interface {
    FindByID(id string) (*User, error)
    Save(user *User) error
    Delete(id string) error
}

type EmailService interface {
    Send(to Email, subject string, body string) error
}

type Cache interface {
    Set(key string, value interface{}) error
    Get(key string) (interface{}, error)
}

Testing con Hexagonal Architecture

package application

import "testing"

// Mock del repositorio para testing
type MockAccountRepository struct {
    accounts map[string]*domain.Account
}

func (mar *MockAccountRepository) FindByID(id string) (*domain.Account, error) {
    acc, ok := mar.accounts[id]
    if !ok {
        return nil, errors.New("not found")
    }
    return acc, nil
}

func (mar *MockAccountRepository) Save(account *domain.Account) error {
    mar.accounts[account.ID()] = account
    return nil
}

// Mock del event bus
type MockEventBus struct {
    published []domain.DomainEvent
}

func (meb *MockEventBus) Publish(event domain.DomainEvent) error {
    meb.published = append(meb.published, event)
    return nil
}

// El test puede usar mocks en lugar de adaptadores reales
func TestCreateAccount(t *testing.T) {
    repo := &MockAccountRepository{accounts: make(map[string]*domain.Account)}
    eventBus := &MockEventBus{}

    useCase := application.NewCreateAccountUseCase(repo, eventBus, nil)

    accountID, err := useCase.Execute(email, balance)

    if err != nil {
        t.Fatalf("unexpected error: %v", err)
    }

    if len(eventBus.published) == 0 {
        t.Error("should publish event")
    }
}

Composition Root (Go 1.25)

El “composition root” es donde inyectas todas las dependencias. Go 1.25 hace esto limpio:

package main

import (
    "database/sql"
    "domain"
    "infrastructure"
    "application"
)

// CompositionRoot construye la aplicación
// Archivo: cmd/main.go (parte del archivo)
func CompositionRoot() (*application.CreateAccountUseCase, error) {
    // Infraestructura
    db, err := sql.Open("postgres", "postgresql://...")
    if err != nil {
        return nil, err
    }

    // Adaptadores
    accountRepo := infrastructure.NewPostgresAccountRepository(db)
    eventBus := infrastructure.NewKafkaEventBus()
    emailService := infrastructure.NewGmailNotificationService()

    // Use Case
    useCase := application.NewCreateAccountUseCase(
        accountRepo,
        eventBus,
        emailService,
    )

    return useCase, nil
}

func main() {
    useCase, err := CompositionRoot()
    if err != nil {
        panic(err)
    }

    // Usar
    accountID, _ := useCase.Execute(email, balance)
}

Resumen de Parte 6

  • Hexagonal Architecture aísla el dominio del exterior
  • Puertos son interfaces que define el dominio
  • Adaptadores son implementaciones concretas
  • El dominio nunca conoce a los adaptadores
  • Fácil de testear con mocks
  • Escalable y flexible para cambios de tecnología

Próximos Pasos

Ahora tenemos todos los piezas: DDD limpio + Arquitectura Hexagonal.

El siguiente paso es juntarlo todo en una aplicación completa funcional que muestre cómo todo trabaja junto.


Parte 7: Aplicación Completa - Sistema de Transferencias Bancarias {#aplicacion-completa}

El Caso: Un Micro Banco Hipotético

Vamos a construir un mini-banco que permite:

  1. Crear cuentas
  2. Depositar y retirar dinero
  3. Transferir entre cuentas
  4. Gestionar límites de transferencia

Todo usando DDD + Hexagonal.

La Estructura Completa

// ==============================================================================
// DOMAIN LAYER: El core del negocio bancario
// ==============================================================================

// Archivo: domain/money.go - Ya lo escribimos

// Archivo: domain/account.go
// Paquete: domain
// Concepto: Account Entity - Cuenta bancaria con balance y límites

package domain

import (
    "errors"
    "time"
)

type AccountID string

type AccountStatus string

const (
    AccountStatusActive    AccountStatus = "active"
    AccountStatusSuspended AccountStatus = "suspended"
    AccountStatusClosed    AccountStatus = "closed"
)

type Account struct {
    id              AccountID
    ownerID         string
    balance         Money
    status          AccountStatus
    dailyWithdrawLimit Money
    totalWithdrawnToday int64 // en centavos
    lastWithdrawDate  *time.Time
    createdAt       time.Time
}

func NewAccount(
    id AccountID,
    ownerID string,
    initialBalance Money,
) (Account, error) {
    if ownerID == "" {
        return Account{}, errors.New("owner id required")
    }

    return Account{
        id:                 id,
        ownerID:            ownerID,
        balance:            initialBalance,
        status:             AccountStatusActive,
        dailyWithdrawLimit: newMoney(500000, "USD"), // $5000 diarios
        totalWithdrawnToday: 0,
        createdAt:          time.Now(),
    }, nil
}

func (a Account) ID() AccountID { return a.id }
func (a Account) Balance() Money { return a.balance }
func (a Account) OwnerID() string { return a.ownerID }
func (a Account) Status() AccountStatus { return a.status }

func (a *Account) Deposit(amount Money) error {
    if a.status != AccountStatusActive {
        return errors.New("cannot deposit to inactive account")
    }

    newBalance, err := a.balance.Add(amount)
    if err != nil {
        return err
    }

    a.balance = newBalance
    return nil
}

func (a *Account) Withdraw(amount Money) error {
    if a.status != AccountStatusActive {
        return errors.New("cannot withdraw from inactive account")
    }

    if amount.Amount() > a.balance.Amount() {
        return errors.New("insufficient funds")
    }

    // Check daily limit
    now := time.Now()
    if a.lastWithdrawDate != nil {
        if now.Format("2006-01-02") != a.lastWithdrawDate.Format("2006-01-02") {
            a.totalWithdrawnToday = 0 // Reset daily counter
        }
    }

    if a.totalWithdrawnToday+amount.Amount() > a.dailyWithdrawLimit.Amount() {
        return errors.New("daily withdrawal limit exceeded")
    }

    newBalance, err := a.balance.Subtract(amount)
    if err != nil {
        return err
    }

    a.balance = newBalance
    a.totalWithdrawnToday += amount.Amount()
    a.lastWithdrawDate = &now

    return nil
}

// domain/repositories.go
package domain

// AccountRepository define cómo accedemos a las cuentas
type AccountRepository interface {
    FindByID(id AccountID) (*Account, error)
    Save(account *Account) error
}

// TransactionLog registra cada transacción
type TransactionLog interface {
    LogTransaction(from AccountID, to AccountID, amount Money) error
}

// domain/services.go
package domain

// TransferService orquesta transferencias bancarias
type TransferService struct {
    accountRepo AccountRepository
    txLog       TransactionLog
}

func NewTransferService(
    repo AccountRepository,
    txLog TransactionLog,
) *TransferService {
    return &TransferService{
        accountRepo: repo,
        txLog:       txLog,
    }
}

func (ts *TransferService) Transfer(
    fromID AccountID,
    toID AccountID,
    amount Money,
) error {
    // Obtener ambas cuentas
    from, err := ts.accountRepo.FindByID(fromID)
    if err != nil {
        return errors.New("source account not found")
    }

    to, err := ts.accountRepo.FindByID(toID)
    if err != nil {
        return errors.New("destination account not found")
    }

    // Validar que no sea la misma cuenta
    if from.ID() == to.ID() {
        return errors.New("cannot transfer to same account")
    }

    // Validar monedas iguales
    if from.balance.Currency() != amount.Currency() {
        return errors.New("currency mismatch")
    }

    // Realizar retiro
    if err := from.Withdraw(amount); err != nil {
        return err
    }

    // Realizar depósito
    if err := to.Deposit(amount); err != nil {
        // ROLLBACK
        from.Deposit(amount)
        return errors.New("deposit failed, transfer rolled back")
    }

    // Persistir
    if err := ts.accountRepo.Save(from); err != nil {
        from.Deposit(amount)
        to.Withdraw(amount)
        return errors.New("failed to save source account")
    }

    if err := ts.accountRepo.Save(to); err != nil {
        from.Deposit(amount)
        to.Withdraw(amount)
        return errors.New("failed to save destination account")
    }

    // Log transaction
    ts.txLog.LogTransaction(from.ID(), to.ID(), amount)

    return nil
}

// ==============================================================================
// APPLICATION LAYER: Casos de uso
// ==============================================================================

// Archivo: application/use_cases.go
// Paquete: application
// Responsabilidad: Orquestación de casos de uso

package application

import "domain"

type CreateAccountUseCase struct {
    accountRepo domain.AccountRepository
}

func NewCreateAccountUseCase(repo domain.AccountRepository) *CreateAccountUseCase {
    return &CreateAccountUseCase{accountRepo: repo}
}

type CreateAccountRequest struct {
    OwnerID        string
    InitialBalance int64 // en centavos
}

type CreateAccountResponse struct {
    AccountID string
}

func (cau *CreateAccountUseCase) Execute(req CreateAccountRequest) (*CreateAccountResponse, error) {
    id := domain.AccountID(generateUUID())
    balance, _ := domain.NewMoney(req.InitialBalance, "USD")

    account, err := domain.NewAccount(id, req.OwnerID, balance)
    if err != nil {
        return nil, err
    }

    if err := cau.accountRepo.Save(&account); err != nil {
        return nil, err
    }

    return &CreateAccountResponse{
        AccountID: string(id),
    }, nil
}

// TransferMoneyUseCase
type TransferMoneyUseCase struct {
    transferService *domain.TransferService
}

func NewTransferMoneyUseCase(service *domain.TransferService) *TransferMoneyUseCase {
    return &TransferMoneyUseCase{transferService: service}
}

type TransferMoneyRequest struct {
    FromAccountID string
    ToAccountID   string
    Amount        int64 // en centavos
}

func (tmu *TransferMoneyUseCase) Execute(req TransferMoneyRequest) error {
    amount, _ := domain.NewMoney(req.Amount, "USD")

    return tmu.transferService.Transfer(
        domain.AccountID(req.FromAccountID),
        domain.AccountID(req.ToAccountID),
        amount,
    )
}

// ==============================================================================
// INFRASTRUCTURE LAYER: Adaptadores concretos
// ==============================================================================

// infrastructure/postgres_account_repository.go
package infrastructure

import (
    "database/sql"
    "domain"
)

// Archivo: infrastructure/postgres/account_repository.go
// Paquete: infrastructure.postgres
// Responsabilidad: Implementación PostgreSQL del AccountRepository

type PostgresAccountRepository struct {
    db *sql.DB
}

func NewPostgresAccountRepository(db *sql.DB) *PostgresAccountRepository {
    return &PostgresAccountRepository{db: db}
}

func (par *PostgresAccountRepository) FindByID(id domain.AccountID) (*domain.Account, error) {
    row := par.db.QueryRow(
        "SELECT id, owner_id, balance_cents, status FROM accounts WHERE id = $1",
        string(id),
    )

    var accountID, ownerID, status string
    var balanceCents int64

    if err := row.Scan(&accountID, &ownerID, &balanceCents, &status); err != nil {
        return nil, err
    }

    balance, _ := domain.NewMoney(balanceCents, "USD")
    account, _ := domain.NewAccount(domain.AccountID(accountID), ownerID, balance)

    return &account, nil
}

func (par *PostgresAccountRepository) Save(account *domain.Account) error {
    query := `
        INSERT INTO accounts (id, owner_id, balance_cents, status)
        VALUES ($1, $2, $3, $4)
        ON CONFLICT (id) DO UPDATE SET
            balance_cents = $3,
            status = $4
    `

    _, err := par.db.Exec(
        query,
        string(account.ID()),
        account.OwnerID(),
        account.Balance().Amount(),
        string(account.Status()),
    )

    return err
}

// Archivo: infrastructure/transaction_log.go
// Paquete: infrastructure
// Responsabilidad: Logging de transacciones

package infrastructure

import (
    "domain"
    "log"
)

type MemoryTransactionLog struct {
    transactions []Transaction
}

type Transaction struct {
    From   string
    To     string
    Amount int64
}

func NewMemoryTransactionLog() *MemoryTransactionLog {
    return &MemoryTransactionLog{
        transactions: []Transaction{},
    }
}

func (mtl *MemoryTransactionLog) LogTransaction(
    from domain.AccountID,
    to domain.AccountID,
    amount domain.Money,
) error {
    tx := Transaction{
        From:   string(from),
        To:     string(to),
        Amount: amount.Amount(),
    }

    mtl.transactions = append(mtl.transactions, tx)
    log.Printf("Transaction logged: %s -> %s: %d cents", from, to, amount.Amount())

    return nil
}

// ==============================================================================
// PORTS LAYER: Definiciones de interfaces públicas
// ==============================================================================

// Archivo: ports/interfaces.go
// Paquete: ports
// Responsabilidad: Re-exportar interfaces públicas del dominio

package ports

import "domain"

// Exportar los puertos para uso externo
type AccountRepository = domain.AccountRepository
type TransactionLog = domain.TransactionLog

// ==============================================================================
// MAIN: Composición e inyección de dependencias
// ==============================================================================

// Archivo: cmd/main.go
// Paquete: main
// Responsabilidad: Entry point y composición de dependencias

package main

import (
    "database/sql"
    "log"

    "application"
    "domain"
    "infrastructure"
)

func main() {
    // === Setup Base de Datos ===
    db, err := sql.Open("postgres", "postgresql://user:pass@localhost/bank")
    if err != nil {
        log.Fatalf("Failed to open database: %v", err)
    }
    defer db.Close()

    // Crear tablas si no existen
    createTables(db)

    // === Instanciar Adaptadores ===
    accountRepo := infrastructure.NewPostgresAccountRepository(db)
    transactionLog := infrastructure.NewMemoryTransactionLog()

    // === Instanciar Domain Services ===
    transferService := domain.NewTransferService(accountRepo, transactionLog)

    // === Instanciar Use Cases ===
    createAccountUC := application.NewCreateAccountUseCase(accountRepo)
    transferMoneyUC := application.NewTransferMoneyUseCase(transferService)

    // === Usar la aplicación ===
    runDemo(createAccountUC, transferMoneyUC)
}

func createTables(db *sql.DB) {
    schema := `
    CREATE TABLE IF NOT EXISTS accounts (
        id VARCHAR(36) PRIMARY KEY,
        owner_id VARCHAR(255) NOT NULL,
        balance_cents BIGINT NOT NULL,
        status VARCHAR(50) NOT NULL,
        created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
    );
    `

    if _, err := db.Exec(schema); err != nil {
        log.Fatalf("Failed to create tables: %v", err)
    }
}

func runDemo(
    createAccountUC *application.CreateAccountUseCase,
    transferMoneyUC *application.TransferMoneyUseCase,
) {
    // Crear cuentas
    alice, _ := createAccountUC.Execute(application.CreateAccountRequest{
        OwnerID:        "alice",
        InitialBalance: 100000, // $1000
    })

    bob, _ := createAccountUC.Execute(application.CreateAccountRequest{
        OwnerID:        "bob",
        InitialBalance: 50000, // $500
    })

    log.Printf("Created Alice's account: %s", alice.AccountID)
    log.Printf("Created Bob's account: %s", bob.AccountID)

    // Transferir dinero
    err := transferMoneyUC.Execute(application.TransferMoneyRequest{
        FromAccountID: alice.AccountID,
        ToAccountID:   bob.AccountID,
        Amount:        25000, // $250
    })

    if err != nil {
        log.Fatalf("Transfer failed: %v", err)
    }

    log.Println("Transfer completed successfully!")
}

El Flujo Completo

User Request

HTTP Handler (REST adapter)

UseCase (TransferMoneyUseCase)

Domain Service (TransferService)

Domain Aggregates (Account.Withdraw, Account.Deposit)

Repository (PostgresAccountRepository)

Database

Testing la Aplicación Completa

// application/use_cases_test.go
package application

import "testing"

func TestTransferMoney_Success(t *testing.T) {
    // Setup
    mockRepo := &MockAccountRepository{
        accounts: map[string]*domain.Account{},
    }
    mockTxLog := &MockTransactionLog{}

    // Crear cuentas
    alice, _ := domain.NewAccount("alice-id", "alice", money1000)
    bob, _ := domain.NewAccount("bob-id", "bob", money500)
    mockRepo.accounts["alice-id"] = &alice
    mockRepo.accounts["bob-id"] = &bob

    service := domain.NewTransferService(mockRepo, mockTxLog)
    uc := NewTransferMoneyUseCase(service)

    // Execute
    amount250, _ := domain.NewMoney(25000, "USD")
    err := uc.Execute(TransferMoneyRequest{
        FromAccountID: "alice-id",
        ToAccountID:   "bob-id",
        Amount:        25000,
    })

    // Assert
    if err != nil {
        t.Fatalf("unexpected error: %v", err)
    }

    // Verify balances
    alice := mockRepo.accounts["alice-id"]
    if alice.Balance().Amount() != 75000 { // $1000 - $250
        t.Errorf("alice balance wrong: %d", alice.Balance().Amount())
    }

    bob := mockRepo.accounts["bob-id"]
    if bob.Balance().Amount() != 75000 { // $500 + $250
        t.Errorf("bob balance wrong: %d", bob.Balance().Amount())
    }

    // Verify transaction was logged
    if len(mockTxLog.logged) == 0 {
        t.Error("transaction not logged")
    }
}

func TestTransferMoney_InsufficientFunds(t *testing.T) {
    mockRepo := &MockAccountRepository{}
    mockTxLog := &MockTransactionLog{}

    alice, _ := domain.NewAccount("alice-id", "alice", money100)
    bob, _ := domain.NewAccount("bob-id", "bob", money500)
    mockRepo.accounts["alice-id"] = &alice
    mockRepo.accounts["bob-id"] = &bob

    service := domain.NewTransferService(mockRepo, mockTxLog)
    uc := NewTransferMoneyUseCase(service)

    // Try to transfer more than available
    err := uc.Execute(TransferMoneyRequest{
        FromAccountID: "alice-id",
        ToAccountID:   "bob-id",
        Amount:        50000, // Más que los $100 disponibles
    })

    if err == nil {
        t.Error("should fail with insufficient funds")
    }
}

Flujo HTTP Completo (Bonus)

// Adaptador HTTP (entrada al sistema)
package http

import (
    "net/http"
    "application"
    "encoding/json"
)

type TransferHandler struct {
    uc *application.TransferMoneyUseCase
}

func NewTransferHandler(uc *application.TransferMoneyUseCase) *TransferHandler {
    return &TransferHandler{uc: uc}
}

func (th *TransferHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
    if r.Method != http.MethodPost {
        http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
        return
    }

    var req application.TransferMoneyRequest
    if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
        http.Error(w, "Invalid request", http.StatusBadRequest)
        return
    }

    err := th.uc.Execute(req)
    if err != nil {
        http.Error(w, err.Error(), http.StatusBadRequest)
        return
    }

    w.Header().Set("Content-Type", "application/json")
    json.NewEncoder(w).Encode(map[string]string{
        "status": "success",
        "message": "Transfer completed",
    })
}

// En main.go
func main() {
    // ... setup ...

    http.Handle("/transfer", NewTransferHandler(transferMoneyUC))
    http.ListenAndServe(":8080", nil)
}

// Cliente:
// curl -X POST http://localhost:8080/transfer \
//   -H "Content-Type: application/json" \
//   -d '{"FromAccountID":"alice","ToAccountID":"bob","Amount":25000}'

Resumen de Parte 7

  • DDD en práctica: todos los conceptos juntos
  • Hexagonal Architecture separando dominio de infraestructura
  • Testing simple porque todo es inyectable
  • Escalable: Cambiar PostgreSQL por MongoDB requiere 1 adaptador
  • Mantenible: Lógica de negocio centralizada en el dominio

Próximos Pasos

Próximos Pasos

Casi terminamos. Los últimos dos pasos son:

  1. Parte 8: Antipatrones comunes y decisiones arquitectónicas
  2. Conclusión: Resumen, checklist, y palabras finales

Parte 8: Antipatrones y Decisiones Arquitectónicas {#antipatrones}

Antipatrón 1: Over-Engineering con DDD

DDD no es siempre la respuesta. Usarlo en CRUD simple es sobre-ingeniería.

// ❌ MALO: CRUD simple sobre-engineered con DDD
// Un formulario de contacto con 3 campos

type ContactRequest struct {
    name  string
    email string
    message string
}

// Hacer Value Objects, Entities, Aggregates, Repositories...
// Es exceso para un "INSERT INTO contacts ..."

// ✓ BIEN: Simple cuando es simple
type Contact struct {
    Name    string
    Email   string
    Message string
}

// Simple CRUD suficiente
func CreateContact(c Contact) error {
    db.Exec("INSERT INTO contacts ...")
}

Regla de Oro:

  • Usa DDD si la lógica de negocio es compleja
  • Usa DDD si tienes agregados que interactúan
  • Usa DDD si esperas cambios frecuentes en requisitos
  • Usa simple CRUD si es un form básico o reportes

Antipatrón 2: Puertos que Leakan Infraestructura

// ❌ MALO: El puerto expone detalles de BD
type UserRepository interface {
    Query(sqlQuery string) ([]User, error)  // ¡¡SQL en dominio!!
    FindWithJoin(table1 string, table2 string) []User // ¡¡SQL!
}

// ✓ BIEN: El puerto es neutral
type UserRepository interface {
    FindByID(id string) (*User, error)
    FindByEmail(email Email) (*User, error)
    Save(user *User) error
}

// La BD decide cómo implementarla, no al revés

Antipatrón 3: Domain Services que Son Realmente Use Cases

// ❌ MALO: Un "Domain Service" que mezcla todo
type PaymentProcessingService struct {
    db           *sql.DB
    emailClient  *mail.Client
    stripeAPI    *stripe.Client
    notifyQueue  kafka.Producer
}

func (pps *PaymentProcessingService) ProcessPayment(...) error {
    // SQL directo
    // Email
    // HTTP a Stripe
    // Kafka
    // Todo mezclado
}

// ✓ BIEN: Separar capas
// domain/payment_service.go
type PaymentService struct {
    gateway PaymentGateway // Puerto
}

// application/payment_use_case.go
type ProcessPaymentUseCase struct {
    paymentService *PaymentService
    paymentRepo    PaymentRepository
    emailService   EmailService
    eventBus       EventBus
}

Antipatrón 4: Agregados Que Nunca Terminar

// ❌ MALO: Un agregado demasiado ambicioso
type Customer struct {
    ID           string
    Profile      Profile
    Orders       []Order          // ¡¡Tiene órdenes!!
    Addresses    []Address        // ¡¡Tiene direcciones!!
    Preferences  Preferences      // ¡¡Tiene preferencias!!
    PaymentMethods []PaymentMethod // ¡¡Y pagos!!
    Tickets      []SupportTicket   // ¡¡Y tickets!!
    ReviewsLeft  []Review          // ¡¡Y reviews!!
}

// Problema: Cambios en cualquier parte cargan todo

// ✓ BIEN: Agregados pequeños y enfocados
type Customer struct {
    ID           string
    ProfileData  ProfileData
}

// Otros agregados separados
type CustomerOrder struct { /* ... */ }
type CustomerAddress struct { /* ... */ }
type PaymentMethod struct { /* ... */ }

Antipatrón 5: No Respetar Bounded Contexts

// ❌ MALO: Sin contextos, todo mezcla conceptos
// Order.Customer.Address.Country.Taxes

// Shipping quiere saber de País para calcular costos
// Billing quiere saber de País para tax_id
// Pero cada uno necesita INFORMACIÓN DIFERENTE

// ✓ BIEN: Contextos separados que hablan por eventos
// Bounded Context: SHIPPING
type ShippingAddress struct {
    Street, City, Country string
}

// Bounded Context: BILLING
type BillingCountry struct {
    CountryCode string
    TaxID       string
}

// Se comunican vía eventos, no compartiendo modelos
type OrderPlaced {
    OrderID string
    ShippingAddress ShippingAddress
    BillingCountry BillingCountry
}

Cuándo NO Usar DDD

SituaciónRecomendación
CRUD simpleUsa simple, NO DDD
Reportes/AnalyticsQuery models, NO domain
Prototipo rápidoPragmatismo > Pureza
Equipo sin experienciaAprende primero, luego aplica
Requisitos establesPodría ser overkill
Lógica de negocio compleja✓ DDD ideal
Múltiples contextos✓ DDD ideal
Cambios frecuentes✓ DDD ideal

Decisión 1: ¿Usar DDD + Hexagonal en TODO?

No. Arquitectura graduada:

// Capa de presentación: Simple
// REST → DTO → Use Case

// Capa de aplicación: Orquestación simple
// Use Case → Domain Service → Domain

// Capa de dominio: AQUÍ es donde va DDD
// Ubicación: domain/
// Value Objects, Entities, Aggregates

// Capa de infraestructura: Simple
// Repository → DB/API
// Ubicación: infrastructure/

Decisión 2: ¿Qué tan grandes deben ser los Agregados?

Regla práctica:

  • Small: 1-5 Entities (mejor en 99% de casos)
  • Medium: 5-20 Entities (raras veces)
  • Large: >20 Entities (reinicia el diseño)
// ✓ BIEN
type ShoppingCart struct {
    id    string
    items []CartItem  // 10-100 items
}

// ❌ MALO
type Website struct {
    users       []User       // 1M usuarios
    products    []Product    // 1M productos
    orders      []Order      // 10M órdenes
    // ...
}

Decisión 3: ¿Event Sourcing es Obligatorio?

No. Es una herramienta, no un requisito:

  • Con Event Sourcing: Complejidad pero auditoria natural
  • Sin Event Sourcing: Más simple, pero pierdes historial
// Simple: Sin Event Sourcing
func (a *Account) Withdraw(amount Money) error {
    a.balance = a.balance - amount // Cambio directo
}

// Complejo: Con Event Sourcing
func (a *Account) Withdraw(amount Money) error {
    a.events = append(a.events, WithdrawalOccurred{...})
    // Se reconstruye desde eventos
}

Recomendación: Comienza simple. Event Sourcing si lo necesitas.

Decisión 4: ¿Transacciones ACID en Agregados?

Sí, pero a nivel de agregado:

// ✓ BIEN: Transacción dentro del agregado
func (o *Order) MarkAsPaid() error {
    if !o.canBePaid() {
        return error
    }
    o.status = Paid
    o.paidAt = now
    o.items.lock()  // Todo dentro es transaccional
    return nil
}

// ❌ MALO: Transacción distribuida entre agregados
// (Aquí necesitarías Saga Pattern)
func Transfer(from Account, to Account) error {
    // Dos agregados = difícil transacción ACID
    // Mejor usar Saga o compensación
}

Decisión 5: ¿Lógica en Entity o Service?

Regla:

  • Si toca solo este objeto → Entity
  • Si toca múltiples objetos → Domain Service
// ✓ Entity
func (o *Order) MarkAsPaid() error {
    // Toca solo esta orden
}

// ✓ Domain Service
func (ts *TransferService) Transfer(from, to, amount) error {
    // Toca dos cuentas = Service
}

SOLID en DDD

DDD + SOLID = Oro:

  • Single Responsibility: Entity solo valida su estado
  • Open/Closed: Domain Services extensibles vía interfaces
  • Liskov Substitution: Puertos intercambiables
  • Interface Segregation: Pequeños, enfocados
  • Dependency Inversion: Depender de puertos, no implementaciones

Testing Strategy

// Nivel 1: Unit - Domain Objects
func TestMoneyAdd(t *testing.T) { /* Trivial */ }

// Nivel 2: Integration - Domain Services
func TestTransferService(t *testing.T) {
    // Mock repositories
}

// Nivel 3: System - Use Cases
func TestCreateOrderUseCase(t *testing.T) {
    // Mock todo
}

// Nivel 4: E2E - Sistema completo
func TestOrderFlow(t *testing.T) {
    // Real DB, real API
}

// Pirámide:
//        /\
//       /E2E\
//      /System\
//     /Integr.\
//    /Unit     \
//   /__________\

Resumen de Parte 8

  • DDD no es para todo - usa cuando haya complejidad
  • Agregados pequeños - mejor que grandes
  • Respeta bounded contexts - evita el acoplamiento
  • Simple es mejor - no sobre-engineerices
  • Pruebas graduadas - más unit, menos E2E
  • Decisiones pragmáticas - adaéptate al contexto

Conclusión y Checklist {#conclusion}

El Viaje Recorrido

Empezamos con un problema simple:

¿Por qué después de 6 meses el código se vuelve un lío?

La respuesta fue Domain-Driven Design.

Recorrimos:

  1. Value Objects: Valores inmutables que representan conceptos
  2. Entities: Objetos con identidad y ciclo de vida
  3. Aggregates: Clusters con fronteras de consistencia
  4. Domain Services: Lógica entre agregados
  5. Ports & Adapters: Aislando el dominio
  6. Una aplicación completa: Todo junto funcionando
  7. Decisiones prácticas: Cuándo y cómo aplicar

Checklist del Developer DDD Go 1.25

Conceptos Fundamentales:

  • Entiendo la diferencia entre Anemic y Rich domain models
  • Sé por qué DDD importa en sistemas complejos
  • Conozco los 4 patrones fundamentales (VO, Entity, Aggregate, Service)

Value Objects:

  • Creo Value Objects que siempre son válidos
  • Mis VOs son inmutables
  • Comparo VOs por valor, no referencia
  • Tengo tests para mis VOs (triviales de pasar)

Entities:

  • Mis Entities tienen identidad única
  • Identidad nunca cambia
  • Cambios de estado van a través de métodos, no campos públicos
  • Tengo validaciones de transiciones de estado

Aggregates:

  • Mis agregados son pequeños (< 20 Entities)
  • Un Aggregate Root como punto de entrada
  • Encapsula validaciones de consistencia
  • Communication entre agregados es eventual

Domain Services:

  • Mis servicios orquestan, no implementan lógica
  • Son stateless
  • Tienen contratos claros (interfaces)
  • No mezclan infraestructura

Arquitectura Hexagonal:

  • El dominio es puro, sin infraestructura
  • Puertos son interfaces que define el dominio
  • Adaptadores implementan puertos
  • Fácil cambiar implementaciones

Testing:

  • Puedo testear Value Objects sin setup
  • Puedo testear Services con mocks simples
  • Tengo cobertura de casos de negocio clave
  • Tests no conocen detalles de BD

Decisiones:

  • Sé cuándo aplicar DDD (lógica compleja)
  • Sé cuándo NO aplicar DDD (CRUD simple)
  • Entiendo trade-offs de arquitectura
  • Puedo explicar mis decisiones al equipo

Recursos para Continuar

Libros (el canon DDD):

  • “Domain-Driven Design: Tackling Complexity in the Heart of Software” - Eric Evans (el original)
  • “Implementing Domain-Driven Design” - Vaughn Vernon (práctico)
  • “Building Microservices with Domain-Driven Design” - Sam Newman

Artículos y Blogs:

En Go:

Práctica:

  1. Toma un proyecto real
  2. Identifica el dominio complejo
  3. Mapea Value Objects y Entities
  4. Define Aggregates
  5. Extrae Domain Services
  6. Implementa Ports & Adapters

Comparación: Antes vs Después

ANTES (Anemic Model):

var balance float64
balance -= amount  // ¿Qué pasa si es negativo?

if user.Admin {
    // Lógica esparcida
}

type User struct {
    Fields map[string]interface{}  // Dinámico, no type-safe
}

DESPUÉS (DDD + Rich Model):

balance, _ := balance.Subtract(amount)  // Validado
if err := user.Suspend(); err != nil {  // Controlado
    // Manejar
}

type User struct {
    id     UserID
    email  Email
    status UserStatus  // Type-safe
}

La Realidad de Go 1.25 + DDD

Go 1.25 hace a DDD naturalmente expresivo:

  • Interfaces pequeñas = Puertos claros
  • Type system = Value Objects con validación
  • Generics = Agregados reutilizables
  • Simplicity = Menos ruido, más concentración en dominio
// Go 1.25 es minimalista pero podente para DDD
type Money struct {
    amount   int64
    currency string
}

// Eso es. Sin necesidad de frameworks pesados.

Palabras Finales

DDD no es un conjunto de patrones que memorizas.

DDD es una forma de pensar sobre software.

Es preguntarte:

  • ¿Qué es lo más importante aquí?
  • ¿Dónde vive la verdad del negocio?
  • ¿Cómo me aseguro que sea siempre consistente?
  • ¿Cómo puedo cambiar sin romper todo?

La respuesta está en el código. En el dominio limpio, bien encapsulado, expresivo.

Cuando aplicásegun, verás que:

  • Code reviews: Más simples porque la lógica es clara
  • Nuevos requisitos: Menos miedo porque el dominio es aislado
  • Debugging: Más fácil porque sabes dónde buscar
  • Escalada: Natural porque la arquitectura lo permite

“The code should express the concepts of the problem domain. When you read it three months from now, it should feel like you’re reading about the problem, not fighting with the technology.”

Eso es DDD.

Última Checklist: ¿Estás Listo?

  • Entiendes qué es DDD y por qué importa
  • Puedes identificar Value Objects vs Entities en tu código
  • Sabes diseñar Agregates con fronteras de consistencia
  • Diseñas puertos antes de adaptadores
  • Tus tests son simples y no requieren setup complejo
  • Puedes explicar a tu equipo por qué cada decisión
  • Iniciarás tu próximo proyecto con DDD (si lo justifica)

Si respondiste sí a la mayoría, ya eres un developer DDD competente en Go 1.25.5.

Ahora:

Ve y construye.

Diseña sistemas que duren. Escribe código que hable de negocios. Crea arquitecturas que escalen.

El futuro del software es código que expresa intención. Y eso es DDD.


¿Te pareció útil esta guía? Compártela con desarrolladores que quieran dominar arquitectura en Go.

¿Tienes preguntas? DDD es un viaje, no un destino. Sigue aprendiendo, experimentando, y mejorando.

¿Encontraste un error o una mejora? La retroalimentación es oro. Considéra abrir un issue o contribución.

Gracias por recorrer este viaje de 0 a experto en DDD con Go 1.25.5.

Que tu código sea limpio, tu dominio claro, y tus arquitecturas escalables.

Tags

#Go #DDD #Domain-Driven Design #Hexagonal #Architecture #Software Architecture