Casos reales/ServerStack Journal

Auditamos nuestros propios microservicios: lo que encontramos en notification-service (y por qué te lo contamos)

Después del éxito de auditar el servidor entero (post 24), repetimos el ejercicio sobre uno de nuestros microservicios .NET en producción: notification-service. Tiene patrones DDD bien hechos (Outbox, Inbox, multi-tenant) pero también deudas operativas concretas (pipeline con `|| true`, 0 tests, sin métricas). Esta es la auditoría honesta — qué encontramos, qué cambiamos, y la lección para cualquiera con microservicios en producción.

Hace meses publicamos Auditamos nuestro propio servidor — esto encontramos. La idea era simple: en vez de escribir un post de "10 best practices para servidores", contar honestamente qué encontramos al revisar el nuestro. Tuvo más tracción que cualquier post de listas que hayamos escrito.

Repetimos la fórmula a nivel de aplicación. Auditamos uno de nuestros microservicios .NET en producción: notification-service. Es el que recibe eventos de los distintos servicios del sistema, genera notificaciones y manda emails vía AWS SES.

Spoiler: encontramos cosas buenas, cosas malas y una lección que vale para cualquiera con microservicios en producción.

Por qué auditar (no por compliance, por operación)

Una auditoría no es escribir un informe de consultora. Es contestar 5 preguntas:

  1. ¿Cómo está construido por dentro? (arquitectura, patrones)
  2. ¿Tiene pruebas reales o solo dichas? (gate de calidad)
  3. ¿Cómo lo monitoreas? (observabilidad)
  4. ¿Qué pasa cuando algo falla? (resiliencia, manejo de errores)
  5. ¿Cuáles son las deudas que aún no duelen pero van a doler? (priorización)

Si el equipo de devs puede responder esas 5 con honestidad y en concreto, no necesitas auditoría. Si no, bienvenido al club.

El servicio bajo examen

notification-service corre como container .NET 8 en nuestro VPS desde marzo 2026. Recibe 13 routing keys distintas del exchange RabbitMQ business-events (de los distintos servicios del sistema), procesa cada evento aplicando lógica de negocio, escribe a una tabla outbox y un proceso aparte la lee y dispara emails vía AWS SES con plantillas branded por producto.

Stack:

  • ASP.NET Core 8.0
  • EF Core 8.0 + Npgsql
  • RabbitMQ.Client 6.8.1
  • AWSSDK.SimpleEmailV2 3.7.406
  • Serilog + Sentry SDK
  • Clean Architecture con vertical slices

Es un servicio "típico" de microservicios event-driven en .NET. Si tienes algo similar, lo que sigue te va a sonar.

Lo bueno: arquitectura sólida

Por orden de importancia:

Outbox pattern presente

La tabla outbox_messages recibe el evento de notificación a generar; un OutboxRelayService corre cada 10 segundos, lee el batch pendiente y envía. Esto te da exactly-once semantics práctica: aunque el SES falle, el evento queda persistido en la base y se reintenta.

Inbox pattern (deduplicación) implementado

Cada consumer hace INSERT INTO inbox_events antes de procesar. Si la inserción tira UNIQUE violation (PostgreSQL SqlState 23505), es porque ya procesamos ese evento — skip + ACK. Sin esto, RabbitMQ que reentrega un mensaje duplicado (cosa que pasa) te genera notificaciones duplicadas a tus clientes.

Multi-tenancy correcto

Cada notificación lleva TenantId + AppCode + SaasId desde el evento original. Las plantillas se resuelven en cascada: si appcode/welcome.html existe, se usa; si no, fallback a default/welcome.html. Permite que FlowOrdr, LifeApp y Forms tengan emails con su propio branding sin código duplicado.

Manejo de poison messages

Si el evento está mal formado (JSON inválido, tipo desconocido), el consumer hace BasicNack(requeue: false) — no vuelve a la cola, no genera loop infinito. Si el error es transitorio (DB caída, SES degradado), BasicNack(requeue: true) para que vuelva a intentarse después.

Health checks útiles

/health simple para liveness, /health/ready que verifica RabbitMQ (vía mgmt API) + Database (Npgsql) — para readiness. Sin esto, los reintentos automáticos del orquestador (Docker, Kubernetes, lo que sea) dan por bueno un container que no está realmente listo.

Lo malo: deudas concretas

Ahora la parte honesta. En orden de severidad:

🔴 P0 — Pipeline con || true

Esta es la peor. En el archivo azure-pipelines.yml, línea ~61:

- script: |
    cd src/NotificationService.Tests
    dotnet test --logger trx || true

Actualización: este hallazgo fue corregido — el || true fue eliminado del pipeline en la siguiente sesión de hardening.

Ese || true al final neutraliza el exit code de dotnet test. Si los tests fallan, el comando del shell devuelve 0 igual y el pipeline pasa al siguiente stage. Es un gate de calidad ilusorio: parece que tienes CI con tests, pero los tests podrían estar fallando hace meses y el pipeline no te avisa.

Lo descubrimos comparando con auth-service (donde ya lo habíamos arreglado en un PR previo) y notamos que notification nunca recibió el mismo fix.

🔴 P0 — Cero tests reales

El proyecto tests/NotificationService.Tests existe, tiene xUnit, tiene coverlet — y tiene 0 archivos con [Fact] o [Theory]. Es un proyecto de tests vacío. Combinado con el || true anterior, el resultado es que cualquier refactor podría romper el outbox relay, los consumers o el sender de SES sin que nadie se dé cuenta hasta que un cliente reportara que no le llegan emails.

🟠 P1 — Sin métricas Prometheus

Cero métricas custom. No sabemos en tiempo real:

  • Cuántos emails se están enviando por minuto.
  • Cuántos están fallando vs entregándose.
  • Cuál es la latencia p95 del envío.
  • Si el outbox tiene cola creciente (signal de SES caído o downstream lento).

Visibilidad operativa cero. Si algo se rompe a las 3 AM, nos enteramos cuando un cliente avisa al día siguiente.

🟠 P1 — Sin DLQ explícito + sin exponential backoff

Si un evento falla 3 veces, queda en la tabla outbox_messages con ProcessedAt = null indefinidamente. Sin alerta. Eventualmente alguien revisa, descubre 200 mensajes pendientes y entonces empiezas a investigar.

Y los retries son a intervalos fijos (10s polling) sin jitter. Si SES cae 5 minutos, todos los reintentos chocan al mismo tiempo cuando vuelve — thundering herd.

🟡 P2 — POST API no idempotente

El endpoint POST /api/v1/notifications/send no tiene header Idempotency-Key. Si el caller (un microservicio cualquiera) hace timeout y reintenta, el cliente recibe el email duplicado. No catastrófico, pero en flujos de "factura mensual" es feo.

🟡 P2 — Aislamiento débil entre tenants

NotificationDbContext declara _tenantId para query filters pero está hardcoded en Guid.Empty. Cada query de admin/listing depende de que el caller filtre manualmente. Una regresión en una sola query podría exponer notificaciones de otros tenants — riesgo bajo pero presente.

La señal más cara: gates de calidad ilusorios

De todos los hallazgos, el más enseñoso es el primer P0 (|| true + 0 tests). No es sólo "tenemos deuda" — es tenemos un gate de calidad que parece funcionar y no atrapa nada.

Eso es peor que no tener gate. Si no tienes gate, sabes que tienes que mirar a mano antes de mergear. Si tienes gate ilusorio, te confías. Y la confianza falsa es donde nacen los incidentes serios.

Mismo patrón pasa con:

  • Backups que corren pero nadie verifica que se restauren (post 42 lo profundiza).
  • Healthchecks que siempre devuelven 200 sin verificar dependencias reales (ver Cuando "healthy" miente).
  • Alertas configuradas pero el SMTP roto no las manda.
  • Tests que solo testean los happy paths.

La pregunta de auditoría que hace daño: ¿cómo verificarías que tu gate ATRAPA algo si lo deliberadamente rompieras?

Comparación con otros servicios

Como tenemos varios microservicios, vale la pena ver cómo se compara notification con sus pares:

Aspectoauthbillingnotification
Outbox pattern✅ con DLQ + retry exp⚠️ sin DLQ⚠️ sin DLQ
Inbox pattern❌ ausente✅ webhook inbox✅ implementado
Tests✅ 29 (post fix)✅ 51 (post fix)❌ 0
Métricas Prometheus
Pipeline gate✅ (post fix)✅ (post fix)❌ tiene `
Idempotency endpoints

Veredicto: notification tiene arquitectura mejor que billing en algunos aspectos (Inbox pattern) pero operacionalmente peor (cero tests, gate ilusorio). La mezcla es típica de servicios que un equipo bueno arquitectó pero después no le dedicó disciplina operativa.

El plan que armamos

Sprint 1.5 dedicado de 4-5 días antes del Sprint 3 (métricas):

TareaEsfuerzo
Quitar `
20-25 unit tests xUnit (NotificationServiceImpl, TemplateService, SesEmailSender, OutboxRelay)2d
4 integration tests con Testcontainers (RabbitMQ + Postgres)1d
Exponential backoff + jitter en outbox y consumer requeue1d
Tabla dlq_messages para mensajes >3 intentos + alerting0.5d
Middleware Idempotency-Key en POST API0.5d
Eliminar MediatR de dependencies (cleanup)0.25d

Total: 5.5 días, 1 dev. Después de eso, notification entra al flow normal de Sprint 3+ (métricas + dashboards).

El rol de GlitchTip / Sentry

Una pieza importante: este servicio ya tiene Sentry SDK integrado, conectado a nuestra instancia self-hosted de GlitchTip (ver post 41 sobre stack de monitoreo). Cada consumer hace body.PushSentryScope() para attach contexto del evento — así, cuando algo explota, vemos en GlitchTip:

  • El payload del evento que causó el crash.
  • El TenantId / AppCode involucrado.
  • La stack trace y la versión del binario.

Eso es 80% del valor de error tracking. El otro 20% (agrupación inteligente, replay, profiling) viene con Sentry SaaS. Para nuestra escala, GlitchTip alcanza.

Lo que aplica a tu caso

Si tienes microservicios en producción, el patrón que ves en este post se repite:

  • Arquitectura buena, ops floja: el equipo dev hizo DDD/Clean correctamente, pero operación quedó débil porque nadie tenía tiempo. Es un technical debt operativo.
  • Gates ilusorios: revisa los pipelines línea por línea buscando || true, continue-on-error: true, set +e, comandos shell que suprimen errores. Es sorprendente cuántos hay en proyectos heredados.
  • Tests vacíos: si dices "tenemos tests" pero nunca alguien dice "tenemos cobertura del 60%+", hay alta probabilidad de que la realidad sea más cerca de 0% que de 60%.
  • Cero métricas custom: si no puedes responder "¿cuántos eventos del tipo X procesamos en la última hora?" desde un dashboard, no tienes observabilidad real, tienes logs.

Si quieres que auditemos los tuyos

Ofrecemos auditorías con esta misma estructura:

  • Lectura del código + pipelines + docker-compose.
  • Pruebas de los healthchecks reales (no las que devuelven 200 siempre).
  • Inventario de deudas P0/P1/P2 con esfuerzo estimado.
  • Plan de remediación priorizado.
  • Si quieres, nosotros mismos ejecutamos el plan.

Tiempo típico para microservicio mediano: 1-2 días. La salida es un documento como este (con los detalles de tu servicio) más una conversación de 60 minutos para discutir prioridades.

Hablemos. En 30 minutos identificamos cuáles son los 2-3 servicios más expuestos y te decimos por dónde empezar.


Relacionado: Auditamos nuestro propio servidor: hallazgos reales · Cuando "healthy" miente · Microservicios con DDD + Outbox pattern · Stack de monitoreo: Prometheus + Grafana + GlitchTip · Microservicios mudos: redes Docker, RabbitMQ y debugging.

Contando…

Preguntas frecuentes

¿Por qué publicar las falencias propias en lugar de barrerlas debajo de la alfombra?

Porque la alternativa — escribir un post de "10 best practices" sin contexto real — es exactamente lo que hace 95% del contenido técnico en internet, y ya nadie lo lee con interés. Compartir lo que encontramos honestamente, incluyendo lo malo, genera más confianza que cualquier informe perfecto. Quien nos contrate sabe lo que va a recibir: ojo crítico real, no maquillaje. Y dentro del equipo, escribir esto nos forzó a priorizar el plan de remediación, así que el post mismo aceleró el fix.

¿Cuánto tiempo lleva una auditoría así?

Para un microservicio de complejidad mediana (notification-service, ~10k LOC, varios consumers, 1 base de datos): 6-10 horas reales de revisión + 2-3 horas para escribir el documento. Para uno chico (un endpoint + DB simple): 3-4 horas. Para uno grande (sistema con múltiples agregados, varios contextos): 2-3 días. Si lo haces con un equipo que conoce el código, baja el tiempo. Si llegas de afuera, sumar tiempo de onboarding al dominio.

¿Qué pasa si la auditoría revela problemas que no podemos arreglar pronto?

Es lo más común y está bien. La auditoría no es un mandato — es un mapa. Te dice dónde estás expuesto. Tu rol como tomador de decisión es decidir cuáles arreglas ahora, cuáles aceptas (con conciencia del riesgo), y cuáles difieres. Aceptar una deuda con conocimiento es diferente a tener una deuda silenciosa. La primera es decisión de negocio; la segunda es accidente esperando.

¿Funciona este enfoque para servicios sin microservicios? (monolitos)

Sí, completamente. La estructura de la auditoría es la misma: arquitectura, pruebas, observabilidad, resiliencia, deudas. Solo cambia que en un monolito haces el ejercicio una vez para toda la app. En microservicios, lo haces por servicio. Para PyME con monolito de 5+ años, el resultado suele ser un inventario más amplio porque las deudas se acumularon en una sola base de código por más tiempo. Eso no es bueno ni malo — es información útil para decidir si refactorizar, modular, o reescribir.

¿Vale la pena hacer auditorías propias internas o necesitamos a alguien externo?

Las dos suman. Auditorías internas regulares (trimestrales, por ejemplo) capturan el 70% de las deudas si el equipo es honesto consigo mismo. Pero el equipo interno tiene puntos ciegos — cosas que están así desde siempre y ya nadie las cuestiona. Una auditoría externa cada 12-18 meses encuentra esos puntos ciegos. La combinación: auditorías internas frecuentes (rutina), auditoría externa anual (perspectiva fresca), y una auditoría externa después de cada incidente importante (post-mortem profundo).

Escrito por

Equipo ServerStack Solutions

Fundador, ServerStack Solutions. Fundador de ServerStack Solutions. Diseño infraestructura y automatización para negocios que quieren dormir tranquilos. Escribo sobre CI/CD, DevOps y herramientas que hacen la diferencia.