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.
Tabla de Contenidos
- Introducción: Por Qué Existe DDD
- Value Objects: Los Bloques Básicos
- Entities: Objetos con Identidad
- Aggregates: Fronteras de Consistencia
- Domain Services: Lógica sin Hogar
- Ports & Adapters: Arquitectura Hexagonal
- Aplicación Completa: Todo Junto
- Antipatrones y Decisiones
- 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ó:
- La lógica está en funciones sueltas, no en objetos
- No hay validaciones en los datos (¿qué pasa si Balance es negativo?)
- El estado se manipula directamente sin protecciones
- No hay expresión del concepto de negocio (¿qué es una “transferencia”?)
- 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
| Aspecto | Modelo Anémico | Rich Domain Model |
|---|---|---|
| Estructura | Structs sin métodos | Tipos con métodos |
| Validación | En servicios sueltos | En el objeto mismo |
| Reglas de negocio | Esparcidas en funciones | Encapsuladas en el tipo |
| Mutabilidad | Campos públicos | Métodos controlados |
| Testabilidad | Difícil (requiere mocks) | Fácil (unidad es el objeto) |
| Mantenibilidad | Baja (dispersión lógica) | Alta (lógica concentrada) |
| Para Go 1.25 | Anti-patrón | Recomendado |
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
| Concepto | Propósito | Mutable | Testeable |
|---|---|---|---|
| Value Object | Representar valores medibles | No | Sí, trivial |
| Entity | Objeto con ciclo de vida único | Sí | Sí, con setup |
| Aggregate | Frontera de consistencia | Sí | Sí, aislado |
| Aggregate Root | Punto de acceso al agregado | Sí | Sí, público |
| Domain Service | Lógica entre agregados | N/A | Sí, con mocks |
| Repository | Persistencia (Puerto) | N/A | Sí, fácil mock |
| Bounded Context | Área conceptual del sistema | N/A | N/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
| Concepto | Ubicación | Nombre del Archivo | Convención |
|---|---|---|---|
| Entity | domain/ | nombre.go | type NombreEntity struct |
| Value Object | domain/ | nombre.go | type NombreVO struct |
| Aggregate | domain/ | nombre.go | type NombreAggregate struct |
| ID de Entity | domain/ | nombre.go | type NombreID string |
| Puerto (Interface) | domain/ | nombre.go | type NombreRepository interface |
| Domain Service | domain/ | services.go | type NombreService struct |
| Use Case | application/ | action.go | type ActionUseCase struct |
| Adaptador | infrastructure/ | adapter/nombre_adapter.go | type NombreAdapter struct |
| Handler HTTP | infrastructure/http/ | handlers.go | func (h *Handler) HandleNoun(w, r) |
| DTO | application/ | dto.go | type 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
MoneyyCurrencyson 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.gopuede 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:
- Stateless: No mantiene estado
- Enfocado: Hace una cosa bien
- Nombrado en el lenguaje del negocio: “OrderProcessor”, no “OrderUtil”
- Recibe y retorna Value Objects o Agregados
- 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:
| Aspecto | Domain Service | Application Service |
|---|---|---|
| Responsabilidad | Lógica de negocio | Orquestación |
| Acceso | Repositorios | Repositorios + APIs externas |
| Transacciones | Negocios | Técnicas |
| Testabilidad | Fácil (solo mocks) | Requiere contexto |
| Ubicación | domain/ | application/ |
| Ejemplo | TransferService | CreateOrderUseCase |
// 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:
- Crear cuentas
- Depositar y retirar dinero
- Transferir entre cuentas
- 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:
- Parte 8: Antipatrones comunes y decisiones arquitectónicas
- 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ón | Recomendación |
|---|---|
| CRUD simple | Usa simple, NO DDD |
| Reportes/Analytics | Query models, NO domain |
| Prototipo rápido | Pragmatismo > Pureza |
| Equipo sin experiencia | Aprende primero, luego aplica |
| Requisitos estables | Podrí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:
- Value Objects: Valores inmutables que representan conceptos
- Entities: Objetos con identidad y ciclo de vida
- Aggregates: Clusters con fronteras de consistencia
- Domain Services: Lógica entre agregados
- Ports & Adapters: Aislando el dominio
- Una aplicación completa: Todo junto funcionando
- 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:
- Standard Go Project Layout (estructura)
- Go Interfaces (ports)
Práctica:
- Toma un proyecto real
- Identifica el dominio complejo
- Mapea Value Objects y Entities
- Define Aggregates
- Extrae Domain Services
- 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.