Diseño de microservicios: descomposición y boundaries

Lectura
20 min~12 min lectura

Diseño de microservicios: descomposición y boundaries

En esta lección, nos adentraremos en el corazón de la arquitectura de microservicios: el arte y la ciencia de descomponer una aplicación monolítica en servicios cohesivos, independientes y mantenibles. Para desarrolladores de Go que buscan construir APIs de alto rendimiento, entender cómo definir los límites correctos entre servicios es la habilidad más crítica para evitar caer en una arquitectura distribuida caótica y frágil. Aprenderemos estrategias de descomposición, patrones para definir límites de contexto (Bounded Contexts), y cómo traducir estos conceptos a una estructura de código Go clara y eficiente, preparando el terreno para un despliegue y escalado robusto.

Fundamentos de la descomposición de servicios

La descomposición es el proceso de dividir una aplicación compleja en unidades funcionales más pequeñas y autónomas: los microservicios. El objetivo no es crear la mayor cantidad de servicios posibles, sino identificar límites de negocio (business capabilities) y límites de cambio (change boundaries) naturales dentro de la aplicación. Un servicio bien diseñado encapsula una capacidad de negocio específica y tiene una razón única para cambiar, siguiendo el principio de responsabilidad única (SRP) a nivel arquitectónico.

En el contexto de Go y las APIs de alto rendimiento, una descomposición efectiva impacta directamente en la velocidad de desarrollo, la resiliencia del sistema y la eficiencia de recursos. Un servicio demasiado grande (un "minilítico") pierde los beneficios de la independencia, mientras que uno demasiado pequeño introduce una complejidad de orquestación y latencia de red que puede anular las ganancias de rendimiento. La clave está en encontrar el equilibrio donde cada servicio pueda ser desarrollado, desplegado y escalado de forma independiente, sin crear un acoplamiento excesivo a través de la red.

Existen varias estrategias para guiar la descomposición. La más poderosa es la derivada del Domain-Driven Design (DDD): identificar Bounded Contexts. Otros enfoques incluyen descomponer por subdominio (core, supporting, generic), por función técnica (API Gateway, servicio de autenticación) o incluso por patrón de acceso a datos. Para sistemas basados en APIs REST, a menudo la descomposición se alinea naturalmente con los recursos y agregados principales de tu dominio.

Concepto clave: Bounded Contexts y agregados

Imagina una gran ciudad. No tiene un solo plano gigante para toda la infraestructura. En su lugar, tiene distritos (financiero, residencial, industrial), cada uno con sus propias reglas, autoridades y servicios internos. La red eléctrica del distrito financiero puede ser independiente de la residencial. Un Bounded Context es precisamente eso: un límite lingüístico y conceptual dentro del cual un término específico (como "cliente" o "producto") tiene un significado único y bien definido. Es el distrito de tu dominio.

Dentro de cada Bounded Context, existen Agregados. Un agregado es un clúster de objetos de dominio (entidades y objetos de valor) que se tratan como una unidad única para cambios de datos. Es la autoridad máxima para un conjunto de reglas de negocio. Por ejemplo, en el contexto de "Pedidos", un agregado Order contendría las líneas de pedido, la dirección de envío y manejaría la lógica para calcular el total. En Go, un agregado a menudo se modela como una struct con métodos que aplican las reglas de negocio y aseguran la consistencia.

Tip del instructor: No intentes modelar un único modelo de dominio universal para toda la empresa. Es una batalla perdida. En su lugar, identifica los distintos contextos donde los conceptos cambian de significado y establece fronteras claras entre ellos. Un "usuario" en el contexto de Autenticación (credenciales, roles) es diferente de un "cliente" en el contexto de Ventas (historial, preferencias).

Cómo funciona en la práctica: De dominio a servicios Go

Vamos a traducir estos conceptos a un proceso práctico para un sistema de comercio electrónico. Supongamos que partimos de un monolito que maneja usuarios, catálogo de productos, carritos de compra, pedidos y envíos. El primer paso es realizar un Event Storming o un análisis de capacidades de negocio para identificar los contextos. Podríamos identificar: Identity & Access Management (IAM), Product Catalog, Shopping Cart, Order Management y Fulfillment.

Ahora, definimos los contratos entre estos contextos. ¿Cómo se comunican? Para APIs REST, esto se traduce en definir APIs HTTP claras. El servicio Shopping Cart necesitará información de productos del Product Catalog, pero en lugar de acoplarse a su base de datos, consumirá su API pública (GET /api/catalog/products/{id}). Aquí es donde el diseño de la API se vuelve crucial: debe ser estable y versionada. En Go, cada uno de estos contextos se convertirá en un repositorio de código independiente, con su propio main.go, su propio router gorilla/mux y sus propias estructuras de datos.

El siguiente paso es modelar los agregados dentro de cada servicio. Para el servicio Order Management, el agregado Order es central. En Go, lo definiríamos como una struct con métodos que controlan su ciclo de vida (Create, Confirm, Cancel), asegurando que el estado siempre sea válido. Este agregado será persistido (por ejemplo, en PostgreSQL) y expuesto a través de endpoints REST como POST /orders, GET /orders/{id}, PATCH /orders/{id}/cancel. La comunicación entre el servicio de Carrito y el de Pedidos podría realizarse mediante una llamada HTTP síncrona al crear el pedido, o de forma asíncrona mediante eventos (por ejemplo, usando un message broker como RabbitMQ) para un mejor desacoplamiento.

Código en acción: Estructura de un servicio y su agregado

Veamos cómo se materializa esto en código Go. Vamos a esbozar la estructura del servicio de Order Management, centrándonos en el directorio, el modelo del agregado y un endpoint clave. Este es un ejemplo simplificado pero funcional.


// Estructura de directorios del proyecto del servicio de pedidos (Order Management)
// cmd/
//   orders-api/
//     main.go
// internal/
//   order/
//     aggregate.go      // Definición del agregado Order
//     repository.go     // Interfaz y implementación del repositorio
//     service.go        // Lógica de aplicación
//   transport/
//     http/
//       handlers.go     // Manejadores HTTP con gorilla/mux
//       routes.go       // Definición de rutas
// pkg/
//   models/             // Modelos compartidos (DTOs para la API)
//   database/           // Configuración de la DB

// internal/order/aggregate.go
package order

import (
    "errors"
    "time"
)

// OrderItem es un Objeto de Valor dentro del agregado Order
type OrderItem struct {
    ProductID string  `json:"product_id"`
    Name      string  `json:"name"`
    UnitPrice float64 `json:"unit_price"`
    Quantity  int     `json:"quantity"`
}

// Order es el Agregado Raíz.
type Order struct {
    ID           string      `json:"id"`
    CustomerID   string      `json:"customer_id"`
    Status       string      `json:"status"` // "pending", "confirmed", "shipped", "cancelled"
    Items        []OrderItem `json:"items"`
    TotalAmount  float64     `json:"total_amount"`
    CreatedAt    time.Time   `json:"created_at"`
    ConfirmedAt  *time.Time  `json:"confirmed_at,omitempty"`
    // version para concurrencia optimista
    Version      int         `json:"-"`
}

// Errores de dominio
var (
    ErrOrderCannotBeCancelled = errors.New("order cannot be cancelled in its current status")
    ErrOrderItemsRequired     = errors.New("order must have at least one item")
)

// NewOrder es una función factory que aplica las reglas de negocio al crear un pedido.
func NewOrder(id, customerID string, items []OrderItem) (*Order, error) {
    if len(items) == 0 {
        return nil, ErrOrderItemsRequired
    }

    total := 0.0
    for _, item := range items {
        total += item.UnitPrice * float64(item.Quantity)
    }

    order := &Order{
        ID:          id,
        CustomerID:  customerID,
        Status:      "pending",
        Items:       items,
        TotalAmount: total,
        CreatedAt:   time.Now().UTC(),
        Version:     1,
    }
    return order, nil
}

// Confirm es un método del agregado que controla la transición de estado.
func (o *Order) Confirm() error {
    if o.Status != "pending" {
        return errors.New("only pending orders can be confirmed")
    }
    now := time.Now().UTC()
    o.ConfirmedAt = &now
    o.Status = "confirmed"
    o.Version++
    return nil
}

// Cancel aplica las reglas para cancelar un pedido.
func (o *Order) Cancel() error {
    if o.Status == "shipped" || o.Status == "delivered" {
        return ErrOrderCannotBeCancelled
    }
    o.Status = "cancelled"
    o.Version++
    return nil
}

// CalculateTotal recalcula el total (útil si los precios cambian externamente).
func (o *Order) CalculateTotal() {
    total := 0.0
    for _, item := range o.Items {
        total += item.UnitPrice * float64(item.Quantity)
    }
    o.TotalAmount = total
}

Ahora, veamos cómo se expone este agregado a través de un handler HTTP usando gorilla/mux. Este handler inyecta el servicio de aplicación que depende del repositorio.


// internal/transport/http/handlers.go
package http

import (
    "encoding/json"
    "net/http"
    "github.com/gorilla/mux"
    "github.com/your-org/order-service/internal/order"
)

// OrderService define la interfaz de la capa de aplicación que necesita el handler.
type OrderService interface {
    CreateOrder(customerID string, items []order.OrderItem) (*order.Order, error)
    GetOrderByID(id string) (*order.Order, error)
    CancelOrder(id string) error
}

type OrderHandler struct {
    service OrderService
}

func NewOrderHandler(s OrderService) *OrderHandler {
    return &OrderHandler{service: s}
}

// CreateOrderHandler maneja POST /orders
func (h *OrderHandler) CreateOrderHandler(w http.ResponseWriter, r *http.Request) {
    var req struct {
        CustomerID string            `json:"customer_id"`
        Items      []order.OrderItem `json:"items"`
    }

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

    createdOrder, err := h.service.CreateOrder(req.CustomerID, req.Items)
    if err != nil {
        // Podríamos mapear errores de dominio a códigos HTTP específicos
        http.Error(w, err.Error(), http.StatusUnprocessableEntity)
        return
    }

    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(http.StatusCreated)
    json.NewEncoder(w).Encode(createdOrder)
}

// GetOrderHandler maneja GET /orders/{id}
func (h *OrderHandler) GetOrderHandler(w http.ResponseWriter, r *http.Request) {
    vars := mux.Vars(r)
    orderID := vars["id"]

    ord, err := h.service.GetOrderByID(orderID)
    if err != nil {
        // Asumimos que el servicio devuelve un error "not found"
        http.Error(w, "Order not found", http.StatusNotFound)
        return
    }

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

// CancelOrderHandler maneja PATCH /orders/{id}/cancel
func (h *OrderHandler) CancelOrderHandler(w http.ResponseWriter, r *http.Request) {
    vars := mux.Vars(r)
    orderID := vars["id"]

    err := h.service.CancelOrder(orderID)
    if err != nil {
        switch err {
        case order.ErrOrderCannotBeCancelled:
            http.Error(w, err.Error(), http.StatusConflict)
        default:
            http.Error(w, "Order not found", http.StatusNotFound)
        }
        return
    }

    w.WriteHeader(http.StatusNoContent)
}

Finalmente, el punto de entrada principal y el enrutamiento con gorilla/mux.


// cmd/orders-api/main.go
package main

import (
    "context"
    "log"
    "net/http"
    "os"
    "os/signal"
    "syscall"
    "time"
    "github.com/gorilla/mux"
    "github.com/your-org/order-service/internal/order"
    orderHttp "github.com/your-org/order-service/internal/transport/http"
    // Se importarían las implementaciones reales de repositorio (ej. en PostgreSQL)
)

func main() {
    // 1. Inicializar dependencias (Repositorio, Servicio)
    // repo := postgres.NewOrderRepository(...)
    // orderService := order.NewService(repo)

    // Para el ejemplo, usamos un servicio simulado.
    var orderService orderHttp.OrderService = &mockOrderService{}

    // 2. Crear el router gorilla/mux
    r := mux.NewRouter()
    r.Use(loggingMiddleware) // Middleware propio

    // 3. Crear y registrar handlers
    orderHandler := orderHttp.NewOrderHandler(orderService)

    // Subrouter para la API v1
    apiV1 := r.PathPrefix("/api/v1").Subrouter()
    apiV1.HandleFunc("/orders", orderHandler.CreateOrderHandler).Methods("POST")
    apiV1.HandleFunc("/orders/{id}", orderHandler.GetOrderHandler).Methods("GET")
    apiV1.HandleFunc("/orders/{id}/cancel", orderHandler.CancelOrderHandler).Methods("PATCH")

    // 4. Configurar y arrancar el servidor
    srv := &http.Server{
        Addr:         ":8080",
        Handler:      r,
        ReadTimeout:  15 * time.Second,
        WriteTimeout: 30 * time.Second,
        IdleTimeout:  60 * time.Second,
    }

    // Manejo graceful de shutdown
    go func() {
        log.Println("Order Service starting on :8080")
        if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
            log.Fatalf("ListenAndServe error: %v", err)
        }
    }()

    // Canal para señales de interrupción
    stop := make(chan os.Signal, 1)
    signal.Notify(stop, os.Interrupt, syscall.SIGTERM)
    <-stop

    log.Println("Shutting down server...")
    ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
    defer cancel()
    if err := srv.Shutdown(ctx); err != nil {
        log.Fatalf("Server forced to shutdown: %v", err)
    }
    log.Println("Server exited properly")
}

func loggingMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        log.Printf("%s %s", r.Method, r.RequestURI)
        next.ServeHTTP(w, r)
    })
}

// mockOrderService es una implementación de ejemplo para compilar.
type mockOrderService struct{}

func (m *mockOrderService) CreateOrder(customerID string, items []order.OrderItem) (*order.Order, error) {
    return order.NewOrder("mock-id", customerID, items)
}
func (m *mockOrderService) GetOrderByID(id string) (*order.Order, error) {
    items := []order.OrderItem{{ProductID: "prod-1", Name: "Mock Product", UnitPrice: 29.99, Quantity: 2}}
    ord, _ := order.NewOrder(id, "cust-123", items)
    ord.Confirm()
    return ord, nil
}
func (m *mockOrderService) CancelOrder(id string) error {
    return nil
}

Errores comunes y cómo evitarlos

Al diseñar los límites de los microservicios, incluso los equipos experimentados pueden cometer errores costosos. Aquí detallamos los más frecuentes y cómo mitigarlos.

1. Descomposición basada en entidades técnicas en lugar de capacidades de negocio: Dividir por capa técnica (un "servicio de base de datos", un "servicio de lógica") es un anti-patrón. Esto crea un acoplamiento estrecho y anula la autonomía. Solución: Siempre parte del dominio. Pregunta: "¿Qué hace este servicio para el negocio?" (Gestionar pedidos, Calcular impuestos) en lugar de "¿Qué tecnología usa?".

2. Límites de contexto demasiado permeables (Shared Database): Permitir que múltiples servicios lean y escriban directamente en la misma base de datos es el camino más rápido hacia el infierno del acoplamiento. Un cambio en el esquema rompe todos los servicios a la vez. Solución: Private Database per Service. Cada servicio es dueño absoluto de sus datos. La comunicación se realiza únicamente a través de APIs bien definidas o eventos asíncronos.

3. Ignorar el patrón Saga para la consistencia distribuida: Intentar usar transacciones distribuidas (XA) de dos fases es complejo y no escala. En su lugar, se deben modelar las operaciones de negocio que cruzan servicios como Sagas (una secuencia de transacciones locales compensables). Por ejemplo, "Crear Pedido" puede involucrar al servicio de Inventario (reservar stock) y al de Pagos (cobrar). Si falla el pago, se debe ejecutar una compensación (liberar stock). Solución: Diseña cada operación transaccional entre servicios como una Saga, usando un orquestador o coreografía basada en eventos.

4. Diseñar APIs demasiado acopladas (Chatty Interfaces): Crear APIs donde el cliente necesita hacer 10 llamadas HTTP secuenciales para construir una sola pantalla (el problema N+1 a nivel de servicio). Esto mata el rendimiento. Solución: Aplica el principio Backends For Frontends (BFF) o diseña APIs compuestas que agregen datos de múltiples servicios en una sola respuesta cuando sea necesario. También considera el patrón API Gateway para agregar requests.

5. Subestimar la complejidad operativa: Creer que 50 microservicios son más fáciles de manejar que un monolito sin invertir en automatización (CI/CD, observabilidad, service mesh). Solución: Adopta una plataforma de despliegue robusta desde el día uno (Kubernetes, Docker Swarm). Implementa logging centralizado, métricas (Prometheus) y trazado distribuido (Jaeger, OpenTelemetry) como parte de la plantilla base de cada servicio en Go.

Checklist de dominio

Antes de considerar completado el diseño de tus límites de microservicios, verifica que cumples con los siguientes criterios. Cada servicio resultante debe poder afirmar lo siguiente:

  • Autonomía de despliegue: ¿Puede este servicio ser desplegado en producción sin necesidad de desplegar simultáneamente ningún otro servicio?
  • Base de datos privada: ¿Tiene este servicio su propio almacenamiento de datos (esquema o base de datos) al que ningún otro servicio accede directamente?
  • Límite de contexto claro: ¿Corresponde este servicio a un Bounded Context o capacidad de negocio bien identificada y nombrada (ej. "Gestión de Catálogo", "Procesamiento de Pagos")?
  • Comunicación desacoplada: ¿Se comunica con otros servicios exclusivamente a través de APIs HTTP/GRPC bien definidas o mediante eventos/mensajes asíncronos?
  • Resiliencia incorporada: ¿El código del cliente para llamar a otros servicios implementa patrones de resiliencia (retries con backoff, circuit breakers, timeouts configurables)?
  • Observabilidad nativa: ¿Expone métricas de salud, logs estructurados y trazas de operaciones que pueden ser correlacionadas a través de los límites del servicio?
  • Ciclo de vida independiente: ¿Puede un equipo pequeño desarrollar, probar y operar este servicio con un grado significativo de independencia de otros equipos?
  • Contrato de API estable: ¿La API pública del servicio está versionada y los cambios son manejados con una estrategia clara (versionado de URI, headers, contenido)?
De lección a portfolio

Convertí esta lección en una habilidad visible para entrevistas.

Guardá el curso, completá los ejercicios y conectá esta habilidad con una ruta de empleo, data, IA, programación o marketing.

Newsletter Cursalo

Recibí rutas y cursos nuevos

Sumate para recibir recursos orientados a empleo y portfolio.

  • Rutas de empleo
  • Cursos prácticos
  • Portfolio y entrevistas

Sin spam. También podés entrar con tu cuenta para guardar progreso. Iniciá sesión