Todos los artículos

Del monolito al BFF: cómo migramos de verdad sin quemarlo todo

Un relato real de la migración de un backend monolítico a un patrón BFF con arquitectura limpia: las decisiones, los errores y los trade-offs de los que nadie te advierte.

7 min de lectura
  • #architecture
  • #backend
  • #nestjs
  • #microservices
  • #clean-architecture

Voy a contarte algo que todo ingeniero senior termina aprendiendo: nadie se despierta una mañana y decide migrar de un monolito por diversión. Lo haces porque el monolito está perjudicando activamente a tu equipo, tus deploys y tus clientes. Eso es exactamente lo que nos pasaba hace unos dos años, y quiero contarte cómo lo logramos en realidad: no la versión depurada de las charlas de conferencias, sino la real.

El monolito pedía ayuda a gritos

Nuestro monolito era una aplicación Node.js con Express que llevaba tres años creciendo. Empezó limpio. No se mantuvo así. Así se veían los síntomas:

  • Miedo a desplegar: un cambio en el módulo de pagos requería desplegar toda la aplicación. Un viernes por la tarde, un arreglo de CSS en el panel de administración tumbó el flujo de checkout. Fue un fin de semana muy divertido.
  • Dependencias enredadas: el servicio de órdenes importaba directamente el repositorio de usuarios. El módulo de notificaciones conocía los niveles de inventario. Todo conocía a todo.
  • Cuellos de botella entre equipos: tres squads trabajando sobre la misma base de código significaban conflictos de merge constantes y una cola de deploys que a veces se acumulaba durante días.
  • Techo de rendimiento: no podíamos escalar el módulo de reportes de forma independiente del flujo de checkout en tiempo real. Ambos vivían en el mismo proceso, compitiendo por los mismos recursos.

El monolito no era solo deuda técnica: era deuda organizacional. Y ahí es cuando sabes que llegó la hora.

Por qué BFF (y no solo “microservicios”)

La reacción instintiva es “¡hagamos microservicios!”. Pero los microservicios sin una estrategia clara de frontend solo mueven los espaguetis del backend a la capa de API. Teníamos tres clientes: una SPA web (Angular), una app móvil (React Native) y un dashboard interno de administración. Cada uno necesitaba formas de datos distintas, flujos de autenticación distintos y características de rendimiento distintas.

Ahí entra Backend for Frontend. La idea es simple: cada cliente tiene su propio backend liviano que orquesta las llamadas a los servicios de dominio y da forma a la respuesta según las necesidades de ese cliente en particular.

La clave fue esta: el BFF no es un servicio de dominio. Tiene cero lógica de negocio. Es una capa de orquestación y transformación. En el momento en que pones reglas de negocio en un BFF, acabas de construir otro monolito con saltos de red adicionales.

Arquitectura hexagonal en los servicios de dominio

Para los servicios de dominio propiamente dichos (órdenes, inventario, usuarios, pagos), optamos por la arquitectura hexagonal. La razón: queríamos que la lógica de dominio estuviera completamente desacoplada de las preocupaciones de infraestructura. ¿Base de datos? Eso es un adapter. ¿Cola de mensajes? Adapter. ¿Pasarela de pagos externa? Adapter.

Esta es la estructura que usamos en NestJS:

src/
├── domain/
│   ├── entities/
│   │   └── order.entity.ts
│   ├── ports/
│   │   ├── order.repository.port.ts
│   │   └── payment-gateway.port.ts
│   └── use-cases/
│       └── create-order.use-case.ts
├── infrastructure/
│   ├── adapters/
│   │   ├── postgres-order.repository.ts
│   │   └── stripe-payment.gateway.ts
│   └── modules/
│       └── order.module.ts
└── application/
    └── bff/
        └── web-bff.controller.ts

La definición del port es limpia y agnóstica a la infraestructura:

// domain/ports/order.repository.port.ts
export interface OrderRepositoryPort {
  findById(id: string): Promise<Order | null>;
  save(order: Order): Promise<Order>;
  findByCustomer(customerId: string, pagination: PaginationParams): Promise<PaginatedResult<Order>>;
}

Y el controller del BFF orquesta sin poseer lógica:

// application/bff/web-bff.controller.ts
@Controller('web/orders')
export class WebBffController {
  constructor(
    private readonly createOrderUseCase: CreateOrderUseCase,
    private readonly getCustomerUseCase: GetCustomerUseCase,
  ) {}

  @Post()
  async createOrder(@Body() dto: CreateOrderDto, @Req() req: AuthRequest) {
    const order = await this.createOrderUseCase.execute({
      customerId: req.user.id,
      items: dto.items,
      shippingAddress: dto.shippingAddress,
    });

    // Responsabilidad del BFF: dar forma a la respuesta para el cliente web
    return {
      orderId: order.id,
      status: order.status,
      estimatedDelivery: order.estimatedDelivery?.toISOString(),
      items: order.items.map(item => ({
        name: item.productName,
        qty: item.quantity,
        total: formatCurrency(item.totalPrice),
      })),
    };
  }
}

Fíjate: el BFF llama al use case y luego transforma la respuesta para el cliente web. El BFF móvil devolvería otra forma, quizás con menos datos para ahorrar ancho de banda, o con URLs de imágenes optimizadas para pantallas móviles.

La estrategia de migración: Strangler Fig

No hicimos una migración big bang. Así es como lo quemas todo. Usamos el patrón strangler fig, y así funcionó en la práctica:

  1. API Gateway como router: pusimos un API Gateway (Kong) delante de todo. Al principio, el 100 % del tráfico iba al monolito.
  2. Extraer un dominio a la vez: empezamos por el servicio de notificaciones porque era el más aislado y el de menor riesgo. Lo construimos como un servicio NestJS independiente con arquitectura hexagonal.
  3. Período de doble escritura: durante unas dos semanas, tanto el monolito como el nuevo servicio procesaron notificaciones. Comparamos las salidas. Así encontramos tres bugs en el servicio nuevo.
  4. Cambio de ruta: cuando tuvimos confianza, actualizamos el gateway para enrutar el tráfico de notificaciones al nuevo servicio. El código del monolito se quedó en su lugar, pero inactivo.
  5. Repetir: órdenes fue lo siguiente, luego inventario y por último pagos (el más aterrador, que dejamos para el final).

Toda la migración tomó unos ocho meses. No ocho días, no ocho semanas. Ocho meses.

Los errores que cometimos

Mentiría si dijera que todo salió sin problemas. Estos son los errores que nos costaron tiempo real:

Error 1: compartir la base de datos durante demasiado tiempo. Al principio dejamos que el nuevo servicio de órdenes leyera de la base de datos del monolito. “Lo separamos después”, dijimos. Ese después tardó cuatro meses, y durante ese tiempo tuvimos un acoplamiento de esquema que echó por tierra la mitad del propósito de extraer el servicio.

Error 2: un BFF que hacía demasiado. En la primera iteración, nuestro BFF web empezó a acumular lógica de negocio. “Es solo una validación pequeña”, dijo alguien. Luego otra. En un mes, el BFF tenía 40 líneas de reglas de negocio que debían vivir en el servicio de dominio. Tuvimos que refactorizarlas de vuelta.

Error 3: no invertir en observabilidad desde el principio. Con un monolito puedes seguir un request en un único flujo de logs. Con servicios distribuidos necesitas trazado distribuido desde el primer día. Agregamos OpenTelemetry en el mes tres. Debió ser el día uno.

Error 4: subestimar la consistencia de datos. Pasar de una única transacción de base de datos a consistencia eventual entre servicios fue un cambio de mentalidad para todo el equipo. Tuvimos que implementar el patrón saga para el flujo orden-pago-inventario, y nos tomó tres iteraciones hacerlo bien.

Los trade-offs de los que nadie te habla

Seamos realistas sobre en qué te estás metiendo:

  • La complejidad operativa aumenta significativamente. Ahora tienes 6 servicios en lugar de 1. Eso son 6 pipelines de CI/CD, 6 conjuntos de logs, 6 health checks, 6 cosas que pueden fallar de forma independiente.
  • El desarrollo local se complica. Docker Compose pasó a ser obligatorio. Incorporar a un nuevo desarrollador pasó de “clonar y ejecutar” a un proceso de configuración de 45 minutos (que con el tiempo automatizamos).
  • La red ahora es un modo de falla. Las llamadas entre servicios pueden agotar el tiempo de espera, fallar o devolver datos desactualizados. Necesitas lógica de reintentos, circuit breakers y estrategias de fallback en todas partes.
  • Las pruebas cambian de raíz. Los tests unitarios son fáciles. ¿Tests de integración entre servicios? Eso requiere contract testing (usamos Pact) y un entorno de staging adecuado.

Pero esta es la otra cara:

  • Los equipos avanzan de forma independiente. El squad de pagos despliega tres veces al día sin coordinarse con nadie.
  • El escalado es quirúrgico. ¿Black Friday? Escala el servicio de órdenes y el de pagos. Deja tranquilo al BFF de administración.
  • Las fallas quedan aisladas. El servicio de notificaciones estuvo caído 20 minutos el mes pasado. Las órdenes siguieron fluyendo. En la época del monolito, eso habría sido una caída total.

La conclusión honesta

Si tu monolito funciona y tu equipo es pequeño, no migres. En serio. Un monolito bien estructurado le gana todos los días a un sistema distribuido mal implementado. Nosotros migramos porque teníamos un dolor real y medible, no porque los microservicios estuvieran de moda.

Pero si estás sintiendo ese dolor (miedo a desplegar, cuellos de botella entre equipos, techos de escalado), entonces una migración bien pensada con BFF + arquitectura hexagonal + strangler fig es un enfoque probado en batalla. Solo entra con los ojos abiertos respecto a los trade-offs, invierte en observabilidad desde el primer día y, por el amor de todo lo bueno en la ingeniería, mantén la lógica de negocio fuera del BFF.

La mejor arquitectura es la que le permite a tu equipo entregar con confianza. Todo lo demás son detalles.