Docente: Maribel Molina Barriga
Arequipa, octubre de 2026
Evaluación del diseño mediante atributos de calidad y evidencias
d5c054fEl caso: OLA y su módulo «Alertas y avisos»
¿Qué hace OLA?
Clasifica la anomalía de la temperatura del mar en los laboratorios costeros de IMARPE y avisa a los usuarios suscritos.
Módulo evaluado
«Alertas y avisos» del backend (RF-01 y RF-03), rama main, commit d5c054f del 13/09/2026.
Evaluar alertas
alert_service.evaluate detecta los episodios de anomalía.
Registrar avisos
create_for_events guarda un aviso pendiente por suscrito y canal.
Enviar
Un administrador dispara send_pending: envía y reintenta los fallidos.
¿Cómo lo evaluamos? Con evidencias
Lista de revisión
8 criterios × 14 unidades = 112 comprobaciones.
Métricas de diseño
LOC · NOM · Ce · Ca · inestabilidad · complejidad ciclomática por módulo.
Guion propio metricas.py (módulo ast), contrastado con ruff (C901).
Re-medición en Docker
Imagen del proyecto ola-api (Python 3.13.15) con PostgreSQL 17.11 desechable: 442 pruebas, ruff, mypy estricto y cobertura.
Escenario de cambio
El mismo cambio, un canal webhook, implementado en los dos diseños y medido con git diff --numstat.
y con su salida en bruto
Diseño actual (E-01): un servicio lo hace todo
Cuatro capas
Routers servicios dominio repositorios. En rosa, las unidades y dependencias con hallazgos.
notification_service
Reúne cinco responsabilidades y construye 4 sentencias SQL:
clock: Ca = 0
Hay un reloj inyectable, pero nadie lo usa: el servicio llama a datetime.now(UTC) en 3 lugares (H-04).
Lo que dicen las métricas y el código
notification_serviceComplejidad baja (máx. 8): falla el reparto
- 1Solo conoce el correo (H-02)
- 2Guarda el estado en el bucle (H-01)
- 3Lee el reloj del sistema (H-04)
- 4Solo se prueba con PostgreSQL (H-05)
def send_pending(session, mailer, *, limit=200):
pendientes = notifications_repo.pending_emails(session, limit=limit)
enviados = fallidos = 0
for notificacion in pendientes:
mensaje = _mensaje(notificacion)
try:
mailer.send(Email(to=notificacion.user.email,
subject=mensaje.subject, body=mensaje.body))
except Exception as exc:
notificacion.status = NotificationStatus.FAILED
notificacion.error = str(exc)[:500]
fallidos += 1
else:
notificacion.status = NotificationStatus.SENT
notificacion.error = None
notificacion.sent_at = datetime.now(UTC)
enviados += 1
session.commit()
return SendSummary(attempted=len(pendientes),
sent=enviados, failed=fallidos)
12 hallazgos, ordenados por prioridad
Regla de prioridad (E-04)
Primero el impacto; a igual impacto, el menor esfuerzo.
Severidad según el impacto
Top 5 y estado
Los 5 priorizados comparten una causa
Cinco responsabilidades
Planifica, persiste, redacta, envía y marca la lectura; además construye SQL.
Canales no extensibles
Un canal nuevo obliga a tocar el servicio, el repositorio, el router y las dependencias.
Envío sin pruebas unitarias
Reintentos y fallos solo se prueban con PostgreSQL: 0 unitarias, 34 de integración.
Inserción duplicada
El mismo insert … on_conflict y su fila, dos veces en el servicio.
Reloj sin usar
Clock existe, pero nadie lo importa: se usa el reloj del sistema.
R1 + R2: canales, despachador y reloj
Un canal nuevo = una clase que cumple
Channel + una línea en default_channels.class NotificationDispatcher:
def __init__(self, store: NotificationStore,
channels: ChannelRegistry, clock: Clock) -> None:
self._store = store
self._channels = channels
self._clock = clock
def send_pending(self, *, limit: int = 200) -> SendSummary:
codigos = [canal.code for canal in self._channels.dispatchable()]
pendientes = self._store.pending(codigos, limit=limit)
enviados = fallidos = 0
for aviso in pendientes:
try:
self._channels.get(aviso.channel).deliver(aviso)
except Exception as exc:
self._store.mark_failed(aviso.id, str(exc)[:MAX_ERROR_LENGTH])
fallidos += 1
else:
self._store.mark_sent(aviso.id, self._clock.now())
enviados += 1
return SendSummary(attempted=len(pendientes),
sent=enviados, failed=fallidos)
Diseño propuesto (E-06) y su costo
notification_serviceProbar la lógica de envío (E-09)
El costo: +16 % de código (1 011 1 173 líneas) y más indirección: el servicio depende de 8 módulos (antes 5). Las 472 pruebas pasan.
Escenario de cambio: un canal webhook
if notificacion.channel is NotificationChannel.WEBHOOK:
webhook.send(WebhookMessage(...))
else:
mailer.send(Email(...))
class WebhookChannel:
code = NotificationChannel.WEBHOOK
requires_dispatch = True
def deliver(self, notice: OutboundNotice) -> None: ...
Decisión (ADR-001) y conclusiones
ADR-001 · Separar el envío en canales, almacén y despachador
Pesos: modificabilidad 30 %, comprobabilidad 25 %, esfuerzo 20 %, riesgo 15 %, comprensibilidad 10 %.
El problema no era la complejidad (ninguna función supera 10), sino la cohesión y la dirección de las dependencias.
Dos refactorizaciones resolvieron los 5 hallazgos priorizados sin modificar ninguna de las 442 pruebas existentes.
La mejora se midió: el canal nuevo toca 2 módulos existentes menos y el bucle no crece, pero no reduce las pruebas que hay que actualizar.
Tiene un costo: +16 % de código y más indirección; 7 hallazgos quedan registrados con su decisión.