CQRS en Go - Arquitectura
Capítulo 15: CQRS en Go - Arquitectura
Go es un lenguaje compilado creado por Google que destaca por su simplicidad, rendimiento y excelente soporte para concurrencia. Estas características lo hacen ideal para sistemas CQRS que manejan alto volumen de operaciones.
Por qué Go para CQRS
Go ofrece ventajas específicas para CQRS:
- Compilación a binario: Deploys simples sin dependencias de runtime
- Goroutines: Concurrencia ligera para procesar múltiples comandos/eventos
- Tipado estático: Errores detectados en compilación, no en producción
- Rendimiento: Cercano a C, ideal para alto throughput
Estructura del Proyecto
La estructura sigue principios de Clean Architecture: el dominio está en el centro, libre de dependencias externas. Las capas externas (infrastructure) dependen de las internas (domain), nunca al revés.
- cmd/: Punto de entrada de la aplicación (main.go)
- internal/: Código privado del proyecto, no importable desde afuera
- pkg/: Código reutilizable que podría usarse en otros proyectos
orderflow-go/
├── cmd/
│ └── api/
│ └── main.go
├── internal/
│ ├── domain/
│ │ ├── order/
│ │ │ ├── order.go
│ │ │ └── events.go
│ │ └── events.go
│ ├── command/
│ │ ├── bus.go
│ │ ├── handler.go
│ │ └── orders/
│ │ ├── create_order.go
│ │ └── add_item.go
│ ├── query/
│ │ ├── bus.go
│ │ ├── handler.go
│ │ └── orders/
│ │ ├── get_order.go
│ │ └── list_orders.go
│ ├── projection/
│ │ ├── manager.go
│ │ └── order_summary.go
│ └── infrastructure/
│ ├── postgres/
│ ├── elasticsearch/
│ └── redis/
├── pkg/
│ └── mediator/
│ └── mediator.go
└── go.mod
Interfaces Base
En Go, las interfaces se definen implícitamente: cualquier tipo que implemente los métodos requeridos satisface la interfaz automáticamente (duck typing).
Generics (introducidos en Go 1.18) permiten crear handlers tipados que trabajan con tipos específicos de comandos.
// internal/command/handler.go
package command
type Command interface {
CommandName() string
}
type Handler[C Command] interface {
Handle(ctx context.Context, cmd C) error
}
type Bus interface {
Dispatch(ctx context.Context, cmd Command) error
Register(cmdName string, handler any)
}
// internal/query/handler.go
package query
type Query interface {
QueryName() string
}
type Handler[Q Query, R any] interface {
Handle(ctx context.Context, q Q) (R, error)
}
type Bus interface {
Ask(ctx context.Context, q Query) (any, error)
Register(queryName string, handler any)
}
Modelo de Dominio
El Aggregate Order encapsula toda la lógica de negocio. Los métodos modifican el estado interno y registran eventos que representan los cambios ocurridos.
PullEvents() extrae los eventos pendientes de publicar, limpiando la lista interna. Este patrón asegura que cada evento se publique solo una vez.
// internal/domain/order/order.go
package order
import "time"
type Order struct {
ID string
CustomerID string
Items []Item
Status Status
Total float64
CreatedAt time.Time
events []DomainEvent
}
type Item struct {
ProductID string
Name string
Price float64
Quantity int
}
type Status string
const (
StatusPending Status = "pending"
StatusConfirmed Status = "confirmed"
StatusShipped Status = "shipped"
)
func NewOrder(id, customerID string) *Order {
o := &Order{
ID: id,
CustomerID: customerID,
Status: StatusPending,
CreatedAt: time.Now(),
}
o.record(OrderCreated{OrderID: id, CustomerID: customerID})
return o
}
func (o *Order) AddItem(item Item) {
o.Items = append(o.Items, item)
o.Total += item.Price * float64(item.Quantity)
o.record(ItemAdded{OrderID: o.ID, Item: item})
}
func (o *Order) record(event DomainEvent) {
o.events = append(o.events, event)
}
func (o *Order) PullEvents() []DomainEvent {
events := o.events
o.events = nil
return events
}
Eventos de Dominio
Los eventos son structs inmutables que representan hechos que ocurrieron en el sistema. Cada evento implementa la interfaz DomainEvent con métodos para identificar el tipo y momento del evento.
// internal/domain/order/events.go
package order
import "time"
type DomainEvent interface {
EventName() string
OccurredAt() time.Time
}
type OrderCreated struct {
OrderID string
CustomerID string
occurredAt time.Time
}
func (e OrderCreated) EventName() string { return "order.created" }
func (e OrderCreated) OccurredAt() time.Time { return e.occurredAt }
type ItemAdded struct {
OrderID string
Item Item
occurredAt time.Time
}
func (e ItemAdded) EventName() string { return "order.item_added" }
func (e ItemAdded) OccurredAt() time.Time { return e.occurredAt }
Mediador (Command/Query Bus)
El Mediator desacopla quién envía comandos/queries de quién los maneja. Usa reflection para invocar dinámicamente el método Handle del handler registrado.
reflect es el paquete de Go para introspección de tipos en tiempo de ejecución. Aunque es poderoso, debe usarse con cuidado por su impacto en rendimiento.
// pkg/mediator/mediator.go
package mediator
import (
"context"
"fmt"
"reflect"
)
type Mediator struct {
handlers map[string]any
}
func New() *Mediator {
return &Mediator{handlers: make(map[string]any)}
}
func (m *Mediator) Register(name string, handler any) {
m.handlers[name] = handler
}
func (m *Mediator) Send(ctx context.Context, request any) (any, error) {
name := reflect.TypeOf(request).Name()
handler, ok := m.handlers[name]
if !ok {
return nil, fmt.Errorf("handler not found for: %s", name)
}
method := reflect.ValueOf(handler).MethodByName("Handle")
results := method.Call([]reflect.Value{
reflect.ValueOf(ctx),
reflect.ValueOf(request),
})
if len(results) == 2 && !results[1].IsNil() {
return results[0].Interface(), results[1].Interface().(error)
}
return results[0].Interface(), nil
}
Repositorios
Los repositorios definen contratos (interfaces) para persistencia. El Write Side usa Repository para guardar aggregates completos. El Read Side usa ReadRepository para obtener vistas optimizadas.
Los struct tags como `json:"id"` indican cómo serializar campos a JSON.
// internal/domain/order/repository.go
package order
import "context"
type Repository interface {
Save(ctx context.Context, order *Order) error
FindByID(ctx context.Context, id string) (*Order, error)
}
type ReadRepository interface {
FindByID(ctx context.Context, id string) (*OrderReadModel, error)
FindByCustomer(ctx context.Context, customerID string) ([]OrderSummary, error)
}
type OrderReadModel struct {
ID string `json:"id"`
CustomerID string `json:"customer_id"`
Items []Item `json:"items"`
Status string `json:"status"`
Total float64 `json:"total"`
}
type OrderSummary struct {
ID string `json:"id"`
Status string `json:"status"`
Total float64 `json:"total"`
}
Resumen
La arquitectura Go para CQRS:
- Separa claramente comandos y queries
- Usa interfaces para desacoplar implementaciones
- Aprovecha generics para handlers tipados
- Mantiene el dominio libre de dependencias
Glosario
Go (Golang)
Definición: Lenguaje de programación compilado, tipado estáticamente, diseñado por Google. Enfatiza simplicidad, concurrencia y eficiencia.
Por qué es importante: Compila a binarios nativos sin dependencias, ideal para microservicios y sistemas de alto rendimiento como CQRS.
Ejemplo práctico: Un servicio CQRS en Go puede manejar 100,000 requests/segundo con latencias de milisegundos, compilando a un binario de 10MB sin runtime externo.
Generics
Definición: Característica de Go 1.18+ que permite escribir funciones y tipos parametrizados que funcionan con múltiples tipos de datos.
Por qué es importante: Elimina duplicación de código al crear handlers que funcionan con cualquier tipo de comando o query.
Ejemplo práctico: Handler[C Command] es un handler genérico que puede especializarse como Handler[CreateOrderCommand] o Handler[AddItemCommand].
context.Context
Definición: Tipo estándar de Go que transporta deadlines, cancelación y valores request-scoped a través de la cadena de llamadas.
Por qué es importante: Permite cancelar operaciones largas, propagar timeouts y pasar metadata (como trace IDs) sin modificar firmas de funciones.
Ejemplo práctico: Si un cliente cancela su request, el Context se cancela y todas las operaciones downstream (queries a DB, llamadas HTTP) pueden terminar anticipadamente.
Reflection (reflect)
Definición: Capacidad de inspeccionar y manipular tipos en tiempo de ejecución. El paquete reflect proporciona esta funcionalidad en Go.
Por qué es importante: Permite crear mediadores genéricos que invocan handlers sin conocer sus tipos específicos en tiempo de compilación.
Ejemplo práctico: El Mediator usa reflection para encontrar y llamar el método Handle del handler correcto basándose en el nombre del comando.
Struct Tags
Definición: Anotaciones de metadata en campos de structs, encerradas en backticks. Procesadas por paquetes como encoding/json o ORMs.
Por qué es importante: Permiten personalizar serialización, validación y mapeo a bases de datos sin modificar nombres de campos.
Ejemplo práctico: `json:"customer_id"` hace que el campo CustomerID se serialice como customer_id en JSON (snake_case en lugar de PascalCase).
Clean Architecture
Definición: Patrón arquitectónico donde las dependencias apuntan hacia adentro: capas externas (infraestructura) dependen de internas (dominio), nunca al revés.
Por qué es importante: El dominio permanece puro y testeable, sin depender de frameworks, bases de datos u otras tecnologías específicas.
Ejemplo práctico: Order en el dominio no importa PostgreSQL ni Elasticsearch. Los repositorios en infrastructure implementan las interfaces definidas por el dominio.
internal/ (Paquete privado)
Definición: Directorio especial en Go cuyo contenido solo puede importarse desde el mismo módulo, no desde proyectos externos.
Por qué es importante: Protege código interno de ser usado como API pública, permitiendo refactorizaciones sin romper compatibilidad.
Ejemplo práctico: Otros proyectos pueden importar orderflow/pkg/mediator pero no orderflow/internal/domain/order.