fastifyangularpythonreact-nativeexpovertexaibigquerygcpfirebasecase-studyllmmobile

Case study: plataforma B2B industrial con directorio, app móvil y LLM integration

Este fue uno de los proyectos freelance más complejos que he ejecutado. Un cliente del sector industrial B2B me contrató para construir una plataforma completa desde cero: un directorio de empresas y servicios, una app móvil para usuarios en campo, un chatbot LLM integrado, mensajería interna, utilidades de gestión, y un pipeline de preparación de datos para ML.

En total: nueve aplicaciones distribuidas, con stacks distintos por bounded context, todo en producción en GCP.

Este post es el registro técnico de cómo lo diseñé, qué decidí y por qué, y qué aprendí operando un sistema poliglota a esta escala.

La plataforma completa — qué había que construir

El cliente tenía múltiples necesidades que no encajaban en un solo producto:

No es un MVP. Es un ecosistema completo que el cliente necesitaba operativo.

Arquitectura del sistema

graph TB
    subgraph "Clientes"
        WEB["Angular 19\n(Web app)"]
        MOB["React Native 0.81\n(iOS + Android)"]
    end

    subgraph "Core — Directorio B2B"
        DIR["api-directorio\n(Fastify + Node.js)\nEmpresas · Servicios · Búsqueda"]
        DIR_PDF["api-directorio-pdf\n(Python FastAPI)\nGeneración PDFs · GCS"]
    end

    subgraph "LLM / AI"
        CHAT["chat-api\n(Python FastAPI)\nVertexAI · BigQuery ML"]
        PYAI["py-ai\n(Python)\nDataset prep · JSONL · GCP upload"]
    end

    subgraph "Mensajería"
        MSG["api-messages\n(Node.js + Express)\nFirestore real-time"]
        NOTIF["notifications-service\n(Node.js + Express)\nFCM · Push · Email"]
    end

    subgraph "Utilidades"
        GOLF["torneo-golf\n(PHP + PHPSpreadsheet)"]
        MENU["menu-digital\n(PHP + widget embed)"]
    end

    subgraph "GCP Infra"
        FS["Firestore"]
        GCS["Cloud Storage\n(GCS)"]
        BQ["BigQuery"]
        VERTEX["Vertex AI"]
        CR["Cloud Run"]
    end

    WEB --> DIR
    WEB --> CHAT
    WEB --> MSG
    MOB --> DIR
    MOB --> MSG
    MOB --> NOTIF
    DIR --> GCS
    DIR_PDF --> GCS
    DIR --> DIR_PDF
    CHAT --> VERTEX
    CHAT --> BQ
    PYAI --> BQ
    MSG --> FS
    NOTIF --> FS
    NOTIF --> MOB
    CR -. hospeda .-> DIR
    CR -. hospeda .-> CHAT
    CR -. hospeda .-> MSG
    CR -. hospeda .-> NOTIF

Nueve aplicaciones en producción. El núcleo es el directorio — ahí está la mayor concentración de lógica de negocio (~11,500 líneas). Todo el resto orbita alrededor de ese core.

La decisión de stack más no obvia: Fastify para el directorio

El instinto en Node.js es Express. Es lo que más conozco, tiene el ecosistema más amplio, y hay miles de ejemplos.

Para el directorio elegí Fastify. Hay una razón concreta: throughput bajo carga de búsqueda.

El directorio tiene un patrón de uso específico: muchas consultas de búsqueda y listado concurrentes, con relativamente pocas escrituras. Esas queries de lectura involucran filtros múltiples (tipo de empresa, ubicación, servicios ofrecidos, certificaciones). Con Express, bajo carga moderada, empecé a ver latencia acumularse en pruebas de carga.

Fastify tiene overhead de routing significativamente menor que Express y serialización JSON optimizada. En benchmarks propios con el schema real del directorio, Fastify manejó ~40% más requests por segundo que Express en el mismo hardware. Para un directorio B2B con carga variable (picos cuando empresas buscan proveedores), ese margen importa.

El trade-off es validación de esquemas más estricta — Fastify usa JSON Schema para validar requests y serializar responses. Más verboso al definir endpoints, pero el beneficio es doble: validación automática en el servidor y documentación auto-generada con @fastify/swagger.

Servicio de PDFs en Python — por qué separarlo del directorio

El directorio necesitaba generar catálogos PDF con el perfil de cada empresa: logo, descripción, lista de servicios, datos de contacto, certificaciones. Layout específico con tablas y margenes del cliente.

Puse eso en un servicio separado en Python FastAPI, no en el servicio Fastify principal.

La razón es la misma que en otros proyectos: las librerías Python para generación de PDFs (reportlab, Pillow) tienen mejor soporte para layouts complejos que las alternativas en Node. pdfkit y puppeteer hacen PDFs en Node, pero cuando el layout tiene reglas específicas de posicionamiento y tablas con contenido dinámico, Python gana en madurez de herramientas.

El servicio PDF recibe los datos del directorio, genera el archivo, lo sube a GCS, y devuelve la URL firmada. El directorio Fastify llama a ese servicio cuando un usuario solicita el catálogo. El cliente ve una URL de descarga; no sabe que hay dos servicios detrás.

LLM integration — VertexAI + BigQuery

El chatbot LLM fue la parte más interesante técnicamente.

El cliente quería que los usuarios pudieran hacer preguntas en lenguaje natural sobre el directorio: “¿qué empresas ofrecen servicios de inspección en Bogotá?”, “muéstrame proveedores con certificación ISO 9001”. Ese tipo de consultas no mapean limpio a una búsqueda por filtros.

Stack elegido: Python FastAPI + VertexAI + BigQuery.

Por qué VertexAI y no OpenAI: el cliente ya tenía cuenta GCP y los datos del directorio estaban en BigQuery. VertexAI permite hacer embedding y consultas sobre esos datos sin mover nada fuera del ecosistema GCP. Eso elimina latencia de red entre servicios y simplifica la surface de seguridad — los datos del directorio nunca salen de GCP para ser procesados.

El flujo de una consulta LLM:

Usuario pregunta en lenguaje natural

  chat-api recibe query

  Embedding de la query (VertexAI text-embedding)

  Búsqueda vectorial en BigQuery ML
  (comparación coseno contra embeddings pre-computados de empresas)

  Top-K empresas relevantes como contexto

  Prompt a VertexAI Gemini con contexto + query original

  Respuesta estructurada al usuario

El pipeline de py-ai se encarga de la preparación del dataset: toma los datos del directorio (JSON desde la API + PDFs de catálogos), los convierte a JSONL, genera embeddings con VertexAI, y los sube a BigQuery. Eso se ejecuta como batch cuando hay actualizaciones en el directorio. El chat-api siempre consulta el dataset pre-computado, nunca genera embeddings on-the-fly durante una consulta — eso hubiera sido demasiado lento para la latencia esperada.

App móvil — React Native + Expo EAS

Los usuarios en campo necesitaban acceso desde teléfono. Las opciones eran:

  1. Web responsive (lo más simple)
  2. React Native bare
  3. React Native + Expo Managed Workflow
  4. Nativo (Swift/Kotlin)

Elegí React Native 0.81 + Expo 54 con Managed Workflow y EAS Build.

La web responsive la descarté porque necesitábamos notificaciones push nativas y acceso offline parcial para zonas con señal intermitente. Nativo hubiera sido dos codebases (iOS + Android) con dos veces el esfuerzo de mantenimiento para una sola persona.

React Native bare vs Expo: Expo Managed Workflow con EAS Build es la respuesta correcta para proyectos freelance donde no tienes un equipo mobile dedicado. Las razones:

EAS Build: CI/CD para mobile sin configurar Xcode ni Android Studio en el servidor. El build corre en la nube de Expo. eas build --platform all genera los binarios listos para publicar en App Store y Play Store. Sin esa abstracción, configurar un pipeline de builds mobile desde cero hubiera costado días.

EAS Submit: automatiza el submission a App Store Connect y Google Play. Un comando, credenciales configuradas, listo.

OTA Updates: las actualizaciones de JavaScript se pueden hacer sin pasar por revisión de la tienda. Cuando hay que corregir un bug en la UI o cambiar lógica de negocio, hago eas update y todos los usuarios reciben el cambio en el próximo launch. Sin OTA, cada fix de UI requeriría un ciclo completo de revisión (días en App Store, horas en Play Store).

La integración con FCM (Firebase Cloud Messaging) para push notifications se maneja a través de expo-notifications. El notifications-service en el backend envía a través de FCM, que distribuye a Android e iOS.

Mensajería en tiempo real — Firestore

Para la mensajería interna usé el stack Firebase que ya conocía: Firestore para persistencia y real-time, FCM para push.

La decisión de Firestore aquí fue pragmática: el cliente no tenía equipo DevOps, y Firestore escala automáticamente sin operaciones. Para un sistema de mensajería donde los bounded contexts están bien definidos, el lock-in de Firestore es un trade-off aceptable a cambio de cero infraestructura que mantener.

El patrón onSnapshot vs polling ya lo tenía resuelto de proyectos anteriores:

const unsubscribe = db.collection('messages')
  .where('conversationId', '==', convId)
  .orderBy('createdAt', 'desc')
  .limit(50)
  .onSnapshot((snapshot) => {
    snapshot.docChanges().forEach((change) => {
      if (change.type === 'added') handleNewMessage(change.doc);
    });
  });

El listener recibe solo deltas, no el snapshot completo. Eso importa cuando una conversación tiene historial largo.

Lo que aprendí en este proyecto que no esperaba: los edge cases de usuarios con múltiples dispositivos. Un usuario con la app móvil y la web abierta al mismo tiempo puede generar estados de “leído/no leído” inconsistentes si no manejas el source of truth correctamente. El estado de notificación vive en Firestore (autoridad), no en el cliente.

El stack poliglota — defendiendo la decisión

Nueve aplicaciones con tres runtimes: Node.js, Python, PHP.

La pregunta obvia es: ¿por qué no estandarizar en un solo lenguaje?

La respuesta está en los bounded contexts. Cada aplicación tiene una responsabilidad clara y un conjunto de herramientas óptimo para esa responsabilidad:

El costo del stack poliglota es real: hay que mantener dependencias, versiones, y convenciones en tres ecosistemas distintos. Lo mitigo con Docker por servicio (cada uno tiene su imagen con su runtime específico) y docker-compose para levantar el sistema completo en desarrollo local.

Infra y CI/CD

Todo el stack vive en GCP:

La consistencia del ecosistema GCP no fue un accidente. IAM centralizado, billing unificado, y los servicios se comunican con identidades de servicio sin credentials en código.

Para CI/CD de la app móvil, EAS Build conectado al repositorio dispara builds en cada push a la rama de producción. Para los servicios backend, Cloud Build con trigger en push a main hace el deploy a Cloud Run.

Números del proyecto

AplicaciónStackLOC aprox
api-directorioFastify + Node.js~11,500
app-móvilReact Native + Expo 54~3,200
py-aiPython (dataset pipeline)~1,200
chat-apiPython FastAPI + VertexAI~1,500
api-directorio-pdfPython FastAPI~800
api-messagesNode.js + Express + Firebase~800
notifications-serviceNode.js + Express + FCM~375
torneo-golfPHP + PHPSpreadsheet~900
menu-digitalPHP~750

Total: ~21,000+ líneas distribuidas en 9 aplicaciones.

Lo que aprendí

Los proyectos poliglotas son manejables con buenos boundaries. El riesgo no es tener múltiples lenguajes — es tener múltiples lenguajes sin claridad de por qué existe cada uno. Cuando cada servicio tiene una responsabilidad definida y hay una razón documentada para su stack, el equipo (o yo solo) puede navegar el sistema sin perderse.

EAS cambió mi relación con mobile. Antes de EAS, el overhead de CI/CD móvil me hacía evitar proyectos que requirieran app nativa. Con EAS, el pipeline de build/submit/OTA es tan predecible como un deploy de backend. Eso abre proyectos que antes hubiera rechazado.

VertexAI + BigQuery es un stack sólido para LLM con datos propios. No es la opción más simple ni la más barata para un caso de uso simple. Pero cuando los datos viven en GCP y el cliente ya está en ese ecosistema, la integración nativa reduce la complejidad operacional de forma significativa. Los embeddings pre-computados en BigQuery ML para RAG son estables y la latencia de consulta es predecible.

El servicio más complejo no es el más “innovador”. El directorio B2B en Fastify es la aplicación de mayor tamaño (~11,500 líneas) y la que más tiempo tomó. No tiene LLM ni mobile — tiene lógica de negocio real, búsqueda con múltiples filtros, gestión de archivos, generación de documentos, y un modelo de datos que refleja cómo funciona un sector industrial específico. La complejidad técnica viene del dominio, no del stack.

Volver al blog