mvvm portal web solventa_1 - martin-hendricks/MISO-PROYECTO-FINAL-SOLVENTA GitHub Wiki
Alcance: solo el canal web. No cubre el cliente móvil, el BFF ni los microservicios. Referencias: caso MISW4501-2026 (§5.1 recorridos críticos, §6 atributos de calidad) y wiki del proyecto (Hoja de trabajo, Estrategia de pruebas §4.2).
El portal web de Solventa no es un sitio de páginas renderizadas por servidor: es una SPA Angular donde la interfaz cambia continuamente en respuesta a datos que llegan de forma asíncrona, parcial y a veces degradada (cotización en curso, perfilamiento con Open Finance, estado de siniestro). En ese contexto:
| Criterio | MVC | MVVM (elegido) |
|---|---|---|
| Acoplamiento Vista↔Modelo | El Modelo notifica a la Vista → acoplamiento residual | Nulo: el ViewModel es el único intermediario |
| Encaje con Angular | Forzado (Angular no tiene "Controller") | Nativo: binding declarativo + signals/RxJS |
| Testabilidad de la lógica de pantalla | Requiere DOM | ViewModel se prueba sin DOM (Karma/Jest) |
| Estados intermedios (loading, degradado) | Difíciles de modelar | Se modelan como estado explícito del VM |
MVVM además habilita directamente dos exigencias del caso: el escenario de dependencia degradada (§6.1) necesita que la vista sepa mostrar "precio referencial en caché" sin que el modelo de dominio conozca la UI; y la facilidad de modificación (§6.5) exige que un cambio de reglas de rating no obligue a tocar componentes visuales.
- Componentes
standaloneconChangeDetectionStrategy.OnPush. -
Cero lógica de negocio, cero HTTP, cero
ifde reglas de dominio. Solo binding y eventos. -
Cero cadenas literales: todo texto viene de catálogos i18n (
ngx-translate/@angular/localize), requisito explícito de la estrategia de pruebas. - Cada elemento interactivo lleva
data-testidestable → habilita el PageObject Model exigido para Cucumber, sin locators frágiles.
<!-- cotizacion.view.html -->
<sv-quote-form
[disabled]="vm.state().status === 'loading'"
(submit)="vm.cotizar($event)">
</sv-quote-form>
<sv-price-card
*ngIf="vm.state().status === 'ready' || vm.state().status === 'degraded'"
[price]="vm.state().data"
[degraded]="vm.state().status === 'degraded'"
[degradedMessage]="'quote.degraded_notice' | translate">
</sv-price-card>Una clase por vista, inyectada en el componente. Expone un solo estado observable y un
conjunto de intenciones. No conoce HttpClient, ni Router, ni el DOM.
export type VmState<T> =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'ready'; data: T; source: 'live' }
| { status: 'degraded'; data: T; source: 'cache'; reason: DegradeReason }
| { status: 'error'; error: UiError };
@Injectable()
export class CotizacionViewModel {
private readonly _state = signal<VmState<PrecioVM>>({ status: 'idle' });
readonly state = this._state.asReadonly();
constructor(private readonly obtenerCotizacion: ObtenerCotizacionUseCase) {}
async cotizar(form: CotizacionFormVM): Promise<void> {
this._state.set({ status: 'loading' });
const r = await this.obtenerCotizacion.execute(CotizacionMapper.toDomain(form));
this._state.set(CotizacionMapper.toVmState(r)); // ready | degraded | error
}
}Puntos clave:
- El estado degradado es de primera clase, no un
catchimprovisado. Es lo que permite cumplir el escenario "un proveedor supera 700 ms o falla → degradar con caché sin exceder el presupuesto". - El ViewModel traduce dominio → ViewModel objects (
PrecioVM), nunca expone DTOs ni entidades crudas a la vista.
-
Modelo de dominio puro TypeScript:
Cotizacion,Poliza,Siniestro,PerfilRiesgo,Consentimiento. Sin decoradores de Angular. -
Casos de uso (
ObtenerCotizacion,EmitirPoliza,PerfilarDeudor,RegistrarSiniestro,OperarPoliza): orquestan dominio + puertos + políticas de resiliencia. -
Puertos (interfaces):
ICotizacionRepo,IPolizaRepo,ISiniestroRepo,IPerfilRepo. Los adaptadores HTTP los implementan. Esto es hexagonal aplicada dentro del front y es lo que hace que cambiar el contrato del BFF no toque ni un ViewModel ni un componente.
| Recorrido | View | ViewModel | Caso de uso | Presupuesto (§6.1) |
|---|---|---|---|---|
| HU-10 Cotización embebida | CotizacionView |
CotizacionViewModel |
ObtenerCotizacion |
p95 ≤ 250 ms |
| HU-19 Suscripción y emisión | SuscripcionEmisionView |
SuscripcionViewModel |
EmitirPoliza |
p95 ≤ 1,5 s |
| HU-58 Perfilamiento hipotecario | PerfilamientoHipotecarioView |
PerfilamientoViewModel |
PerfilarDeudor |
p95 ≤ 400 ms |
| Siniestro asistido (back-office) | SiniestroAsistidoView |
SiniestroViewModel |
RegistrarSiniestro |
consulta p95 ≤ 150 ms |
| HU-59–63 Ciclo de vida de póliza | PolizaLifecycleView |
PolizaViewModel |
OperarPoliza |
p95 ≤ 150 ms |
Cada recorrido es un feature module con lazy loading: es la unidad de despliegue y de cambio. Añadir un ramo nuevo (§6.5 "nuevo ramo de seguro") = añadir un feature + un caso de uso, sin tocar los existentes.
Latencia. El ViewModel emite loading de forma inmediata (feedback < 100 ms percibidos) y el
caso de uso aplica timeout duro de 700 ms por dependencia. El adaptador HTTP hace cache-aside:
si el BFF excede el presupuesto, devuelve el último valor conocido y el VM pasa a degraded.
La telemetría RUM instrumenta p95/p99 por journey, no por endpoint, que es como están escritos
los escenarios del caso.
Disponibilidad. La degradación es un camino de diseño, no una excepción. La caída de un módulo no crítico (p. ej. reportes) no tumba venta ni siniestros porque los features son lazy e independientes.
Seguridad. AuthInterceptor (OAuth2/OIDC + PKCE) y ConsentGuard viven en la capa de adaptadores;
ningún ViewModel manipula tokens. La revocación de consentimiento (≤ 5 min) invalida caché local y
fuerza idle en los VM afectados. El portal no persiste PII en localStorage.
Facilidad de modificación. La regla de dependencia es unidireccional:
View → ViewModel → Caso de uso → Puerto ← Adaptador. Cambiar el proveedor detrás del BFF, o
versionar el contrato a /v2, se absorbe en el adaptador. Cambiar la presentación no toca dominio.
Facilidad de integración. El portal conoce solo el contrato del BFF Web, versionado. Cumple el escenario "cliente web y móvil sobre la misma API": un cambio no obliga a redesplegar ambos clientes a la vez, porque el mapeo DTO↔dominio está aislado.
Testabilidad (§4.2 de la estrategia).
| Nivel | Qué se prueba | Herramienta |
|---|---|---|
| Unitario | ViewModels y casos de uso, sin DOM ni HTTP | Karma/Jasmine + dobles de los puertos |
| Componente | View con ViewModel fake | TestBed |
| Contrato | Adaptador vs. contrato del BFF | HttpTestingController / Pact |
| E2E BDD | Recorridos críticos Given/When/Then | Cucumber + PageObject Model, esperas dinámicas |
| Visual | Regresión de UI | Resemble.js / BackstopJS |
src/app/
core/ # interceptores, auth, consent, telemetría, i18n, errores
domain/ # entidades, value objects, puertos, políticas de resiliencia
application/ # casos de uso (uno por recorrido crítico)
adapters/ # adaptadores HTTP, mappers DTO↔dominio, cache
features/
cotizacion/ { *.view.ts | *.view.html | *.viewmodel.ts | *.spec.ts }
suscripcion/
perfilamiento/
siniestros/
polizas/
ui-kit/ # componentes presentacionales reutilizables
Regla de lint (ESLint boundaries): features/** no puede importar de adapters/**.
Es la barrera que impide que MVVM se degrade a "componentes con HttpClient adentro".
- Sobre-ingeniería. MVVM + puertos en un portal pequeño cuesta código extra. Se justifica aquí por los seis atributos de calidad y por el horizonte de 3 países / 30+ socios.
- Signals vs. RxJS. La propuesta usa signals para el estado del VM y RxJS solo en los bordes (HTTP, debounce). Conviene fijarlo como ADR para evitar mezclas.
-
Dónde vive la caché de degradación. Aquí se propone en el cliente. Si el BFF ya expone
stale-while-revalidate, parte de esta lógica podría bajarse al backend — decisión a coordinar con el equipo del BFF. - Micro-frontends. No se recomiendan en el MVP: el caso pide velocidad con equipo reducido. La modularidad por features deja la puerta abierta si el equipo crece.