Experiencia

Lanzado en
producción.

Cinco años de ingeniería profesional, los últimos tres en un SaaS multi-tenant para la industria del golf y el deporte: encuestas de experiencia del cliente, dashboards de BI e integraciones con terceros. Estas son las piezas que diseñé y construí, las decisiones detrás de ellas, y dónde están los límites de mi propio trabajo.

Escrito como lo explicaría en una entrevista: la ingeniería y el razonamiento, sin nombres de clientes, nombres de proveedores ni detalles internos de implementación.

01 · Casos de estudio

[01]AI_FEEDBACK_INDEXING
Players 1st · SaaS multi-tenant · 2025

El lado de ingesta e indexado de un pipeline RAG

Yo construí la mitad de ingesta e indexado. Los comentarios estaban en el almacén analítico como datos crudos, así que el primer problema fue diseñar una nueva estructura para ellos: qué se convierte en documento, y para cada campo, si tenía que ser SimpleField, SearchableField, sortable, filterable o facetable. Ese esquema es sobre lo que corre todo lo que viene después. Construí un proceso batch para transformar los comentarios históricos e indexarlos, un proceso diario para seguir indexando los nuevos a medida que llegaban, y lancé una versión de prueba para que los clubes pudieran probarlo antes de abrirlo a todos.

PROBLEMA

Los clubes reciben miles de comentarios de texto libre por año, y leerlos todos es un trabajo para el que nadie tiene tiempo. El producto permite preguntar sobre ese feedback en lenguaje natural, lo cual solo funciona si primero se puede encontrar el puñado correcto de comentarios.

DECISIONES QUE DEFIENDO

  • Un club nunca puede ver los comentarios de otro

    El aislamiento entre tenants empieza en el esquema, que fue mi mitad: el ID de cuenta es un campo filtrable en cada documento, así que una búsqueda se puede acotar a un solo club antes de rankear o devolver nada. Eso convierte al aislamiento en una propiedad del índice y no en una instrucción del prompt, y como el modelo no puede emitir una consulta propia, lo que le llega solo pueden ser documentos de un club.

  • El índice guarda el texto, no solo los IDs

    Guardar el texto del comentario y de la pregunta en el índice es lo que lo hace un índice de búsqueda y no una tabla de lookup. Guardar solo IDs implicaría un segundo viaje a la fuente por cada resultado, y el ranking no tendría sobre qué rankear.

LO QUE CONSTRUÍ

  • El esquema del índice y su configuración semántica. La regla que apliqué: buscable solo donde el texto realmente se busca, filtrable donde algo acota una consulta, facetable solo donde la UI ofrece un desglose. Cada capacidad cuesta tamaño de índice, así que no las activás todas.
  • El pipeline que trae los comentarios del almacén analítico, enriquece cada uno con el contexto de su club y su pregunta, los deduplica y los empuja al índice.
  • Cargas por lotes para el backfill histórico: alrededor de mil documentos por lote, tres lotes corriendo en paralelo. Suficiente para mover mucha data, sin que los errores se vuelvan difíciles de razonar.
  • Reintentos con backoff exponencial, y rollback cuando sigue fallando: los documentos parciales se borran del índice y la marca de indexado del comentario queda sin valor, así una ejecución posterior lo vuelve a tomar. Una respuesta nunca queda indexada a medias.
  • Una función programada que mantiene buscables los comentarios nuevos, más la capa de suscripción y cuota de prueba y las APIs detrás de ella.

QUÉ FUE MÍO

Mío: el esquema del índice y su configuración semántica, la ingesta y la transformación, el indexador por lotes con reintentos y rollback, el refresco programado y la capa de cuota de prueba. No mío: el asistente en sí, los prompts, la UI de chat y el endpoint de consulta.

STACK

Azure AI SearchClickHouseMongoDBAzure Functions.NET / C#
[02]BOOKING_INTEGRATIONS
Players 1st · SaaS multi-tenant · 2024

Ingesta diaria desde tres sistemas de reservas externos

Traer números de una API es la mitad fácil. La mitad difícil es que esto corre desatendido todos los días contra tres proveedores distintos, algunos de cuyos clubes van a fallar la autenticación o no devolver nada cualquier día. Y la misma ronda nunca puede contarse dos veces, porque el valor que alimenta es acumulativo.

PROBLEMA

Una asociación nacional quería estadísticas de rondas jugadas para cada uno de sus clubes miembros. Hasta entonces un club tenía que buscar el número y cargarlo a mano, mes tras mes. Eso es lento, inconsistente y, a lo largo de cientos de clubes, simplemente no pasa de forma confiable, así que su panorama nacional siempre estaba incompleto.

DECISIONES QUE DEFIENDO

  • Con un valor acumulativo, el modo de fallo no es una excepción sino un número equivocado que parece correcto

    Cada ronda llega primero como su propia fila cruda en staging. La transformación después suma esas filas por club y mes en un value fact, y las rondas nuevas se suman a la cifra que ya esté ahí. Así que un lote procesado dos veces contra esos mismos datos crudos no lanza absolutamente nada; infla en silencio un número que nadie cuestionaría en meses. La marca de procesado en cada fila de staging y la verificación de crear-o-sumar antes de cada escritura son lo que hace segura una re-ejecución.

  • Una tabla de staging en lugar de escribir directo a las métricas

    Desacoplar extracción de transformación significa que un proveedor caído no puede corromper la tabla de hechos, y que la data se puede reprocesar sin volver a llamar al proveedor. Además te da un lugar donde mirar cuando un número sale mal.

LO QUE CONSTRUÍ

  • Tres extractores, uno por sistema de reservas. Antes de escribir una línea de mapeo, pegué contra el endpoint de cada proveedor y leí el payload crudo. Ni siquiera coincidían en el nombre del campo para rondas de nueve o de dieciocho hoyos.
  • Una única tabla de staging a la que todos normalizan: club, proveedor, cantidad de hoyos, fecha de juego y una marca de procesado. Los proveedores difieren en el borde; pasada esa tabla, nada río abajo sabe ni le importa de qué sistema vino una ronda.
  • Cada club procesado dentro de su propio try/catch, con cualquier error registrado como dato y reintentado en la siguiente corrida. En un job batch multi-tenant, una excepción es un dato, no una señal de frenar. La credencial vencida de un club no debería terminar el día de los otros doscientos.
  • La transformación: agrupar las rondas en staging por club y por mes, separar las de nueve y dieciocho hoyos en métricas distintas, crear el registro contenedor anual si cambió el año, y después crear el hecho o sumar al valor que ya está.
  • Un log estructurado que registra cada resultado, incluido «no había nada que procesar». Corriendo desatendido, no hacer nada en silencio tenía que ser un estado que alguien pudiera ver.

QUÉ FUE MÍO

La plataforma ya tenía un servicio de idempotencia para importaciones, así que enchufé los tres proveedores nuevos ahí en vez de inventar un segundo patrón. Eso es lo que protege el lado de la extracción, y no es mío. La marca de staging y la verificación de crear-o-sumar que protegen la transformación sí lo son, junto con los tres extractores, el servicio de transformación, la tabla de staging y el log de corridas, los handlers de hechos y los tests unitarios.

STACK

.NET / C#Azure FunctionsPostgreSQLMongoDB
[03]SHARE_OF_WALLET
Players 1st · SaaS multi-tenant · 2024

Un tipo de pregunta de encuesta construido sobre búsqueda geoespacial

La parte interesante no es la pregunta. Cualquier herramienta de encuestas puede preguntar «cuántas rondas». Es que la lista de comparación tiene que ser distinta para cada club, generada a partir de la geografía, y mantenerse correcta a medida que cambian los radios y la configuración, sin romper nunca las respuestas históricas que ya apuntan a ella.

PROBLEMA

Toda encuesta de satisfacción le dice a un club qué tan contentos dicen estar sus miembros. Ninguna dice qué porción del juego real de un miembro está capturando el club. Si un golfista juega 30 rondas al año y 15 son en tu club, tu participación es del 50%, y la otra mitad se está yendo a clubes que podés nombrar.

DECISIONES QUE DEFIENDO

  • Una opción de encuesta no es configuración. Es la clave a la que apuntan las respuestas pasadas

    Cuando un club reduce su radio, algunos clubes salen de su lista de comparación. Borrarlos del todo dejaría huérfana cada respuesta histórica que los referenciaba: los números sobreviven, pero ya no podés decir a qué club pertenecían. Así que en cambio se marcan como eliminados, y se reviven con el mismo ID si el radio se vuelve a ampliar, lo que mantiene continua la historia.

  • Un enum, no dos booleanos

    El diseño original tenía flags separados de «usar radio» y «usar lista personalizada». Dos booleanos expresan cuatro estados y dos de ellos son un sinsentido. Un solo enum hace que los estados inválidos no se puedan representar. Es la diferencia entre validar que una combinación es legal y hacer que la combinación ilegal sea imposible de expresar. Un tercer método de selección más adelante se vuelve un valor nuevo del enum en lugar de un tercer flag y todas sus interacciones.

LO QUE CONSTRUÍ

  • Primero hice que los clubes fueran buscables geográficamente. El registro de cuenta tenía una dirección pero no coordenadas, así que agregué una ubicación GeoJSON y usé la consulta geoespacial de MongoDB para encontrar clubes dentro de un radio, de modo que la base de datos hace el trabajo de distancias en vez de traer todo y filtrar en código.
  • El modelo de dominio: el modo de selección como un enum explícito (radio o lista elegida a mano), más filtros por tipo de instalación y límites mínimo/máximo tanto para la lista generada como para lo que un encuestado puede responder.
  • Un servicio de sincronización que mantiene correcta la lista de comparación de cada club a través de cuatro disparadores distintos, agregando clubes cuando entran en rango y marcando como eliminados los que salen.
  • La API de configuración y el editor de la pregunta en la plataforma interna de administración.
  • La transformación de la respuesta: cada respuesta aterriza como filas de hechos, y el dashboard agrupa por club y suma al momento de la consulta. Nada guarda un total por club; el gráfico de distribución se calcula cuando alguien abre el reporte.

QUÉ FUE MÍO

Mío: el tipo de pregunta y su modelo de dominio, la búsqueda geoespacial, el servicio de sincronización, la API, la UI del editor y la rama sobre el pipeline de transformación existente que hace pasar estas respuestas. No mío: la estructura de la lista predefinida de clubes en sí, y cargar las coordenadas reales de cada club. Eso lo hizo un colega con acceso a esa data.

STACK

.NET / C#MongoDBClickHouseBlazor ServerMudBlazor
[04]RETENTION_INTELLIGENCE
Players 1st · SaaS multi-tenant · 2025-26

Módulo de BI de riesgo de abandono, de punta a punta

Para cuando el prompt se construye, la data son seis números agregados de una sola cuenta. No hay nada que filtrar aunque al modelo lo prompteen de forma adversaria.

PROBLEMA

Los clubes podían ver qué tan satisfechos estaban sus miembros, pero no cuáles estaban por irse, ni cuánto costaría perderlos.

DECISIONES QUE DEFIENDO

  • Primero determinista, después generativo

    El trabajo del modelo es redactar, no calcular. Las variaciones de tasa de abandono y las comparaciones se resuelven en código antes de que el prompt exista, porque los LLM no son confiables con la aritmética y un número equivocado en un dashboard de retención es peor que no tener ningún insight. Lo peor que puede hacer acá una mala generación es sonar raro.

LO QUE CONSTRUÍ

  • La tabla de riesgo de abandono por miembro con categorías de riesgo y flujos para contactarlos, tarjetas de KPI, y las vistas de ingresos en riesgo y distribución del riesgo.
  • Cada número al que se refiere un insight: variaciones de tasa de abandono, comparaciones, lo que la historia necesite. Eso llega desde el backend ya calculado. Mi parte es el frontend: mostrarlo, y escribir el prompt base que le entrega esos números terminados al modelo, así el modelo nunca hace aritmética, solo redacta.
  • El ID de cuenta tomado de una cookie httpOnly y revalidado del lado del servidor en cada request, nunca leído del cuerpo del request.
  • La tarjeta de insight transmitida por separado, para que esperar al modelo no bloquee el renderizado de los KPIs y las tablas.
  • Mi primer trabajo en producción con Next.js: App Router, React 19, TypeScript y TanStack Query/Table sobre el backend .NET existente.

QUÉ FUE MÍO

Mío: el dashboard, las tarjetas de KPI, las vistas de ingresos en riesgo y distribución del riesgo, y el prompt base que se le entrega al modelo. No mío: los números sobre los que se arma el prompt, calculados del lado del servidor antes de llegar a mi frontend, la integración que llama al modelo, y el modelo de predicción de abandono en sí, que es del equipo de datos.

STACK

Next.jsReactTypeScriptTanStack Query.NET / C#ClickHouse
[05]SURVEY_PROPAGATION
Players 1st · SaaS multi-tenant · 2024

Propagación de encuestas en organizaciones multi-cuenta

La decisión interesante fue negarse a resolver automáticamente. Un club que personalizó su redacción normalmente lo hizo por algo.

PROBLEMA

Las organizaciones que manejan muchas sedes necesitan que una misma encuesta se mantenga consistente en cada cuenta hija. Pero las cuentas hijas hacen sus propias ediciones locales, así que bajar un cambio no puede simplemente sobrescribir lo que ya está.

DECISIONES QUE DEFIENDO

  • No gana la última escritura

    La versión fácil sobrescribe a la hija y sigue de largo, lo que destruye en silencio una personalización que alguien hizo a propósito. Excluir por defecto a las hijas que divergieron convierte una pérdida silenciosa de datos en una decisión que alguien tiene que tomar.

LO QUE CONSTRUÍ

  • Propagación de los cambios de una encuesta desde una cuenta padre hacia sus hijas.
  • Un diff a tres bandas (el padre antes, el padre después y el estado actual de la hija), con la misma forma que un merge de git. Comparar solo las dos versiones actuales no puede distinguir «nunca divergió» de «personalizado a propósito»; traer el ancestro común sí puede.
  • Las hijas que divergieron quedan excluidas del push por defecto y se le muestran a un administrador, con la sobrescritura forzada como una acción aparte y deliberada.

STACK

.NET / C#MongoDBBlazor ServerMudBlazor

02 · También construí

  • 01CRUD completo para preguntas de encuesta, opciones de respuesta, configuración de escala/NPS, nombres multi-idioma y lógica condicional de salto en la plataforma interna de administración, permitiendo que el equipo configure encuestas desde la UI en lugar de pedirle a un desarrollador que edite registros directamente en la base de datos.
  • 02Distribución de encuestas rediseñada en torno a slugs de URL por recolección, habilitando varias campañas concurrentes por encuesta, con controles de acceso para recolecciones vencidas o canceladas y fallbacks retrocompatibles para los links ya circulando.
  • 03Trabajo continuo sobre la suite de tests de la plataforma, donde soy uno de los que más aporta por cantidad de commits, entre tests unitarios y a nivel de handler.
  • 04Antes, en PwC Argentina: backends orientados a eventos en Azure. Funciones disparadas por colas de Service Bus que enriquecían los mensajes entrantes, procesos encadenados que publicaban a la siguiente cola, y un batch por timer trigger, aplicando CQRS y el patrón repositorio sobre SQL Server y EF Core.

Productos que construí en mi propio tiempo.

mis propios proyectos

$ 03 · Contacto

¿Buscás a
alguien así?

# Estoy buscando activamente mi próximo rol como desarrollador: full-time, en Aarhus o remoto. Con gusto profundizo en cualquiera de estos puntos.

Ponerse en contactodescargar cv