Cambia de proveedor sin desplegar: puertos y adaptadores en Ionic
Una bandera por caso de uso convierte una migración de meses en un cambio de configuración, sin pasar por la tienda.
Tu aplicación móvil integra el SDK de un proveedor externo para algo crítico: firma electrónica, pasarela de pagos, verificación de identidad. Funciona bien. Hasta el día en que el proveedor sube el precio, deprecia la versión que usas o tu equipo de seguridad te pide salir de ahí.
Entonces alguien propone lo obvio en la reunión: «cambiamos el SDK, probamos todo y lo subimos en un release».
El caso de este artículo: una app de banca en Ionic 8 + Capacitor + Angular, publicada en App Store y Google Play, donde once flujos de negocio distintos abren un webview embebido de un motor externo. Ese webview llega como plugin de Capacitor, y hay dos proveedores compitiendo por el puesto.
Cambiarlo de golpe es una migración big bang, y tiene tres problemas que no son técnicos sino de riesgo.
Primero, la superficie de prueba es el producto entero: no puedes certificar once flujos en un sprint, y en móvil cada uno hay que probarlo en las dos plataformas. Segundo, el rollback es una publicación en tienda: si algo falla un viernes, tu tiempo de recuperación incluye la revisión de Apple. Días, no minutos. Tercero, es todo o nada: no hay forma de aprender del flujo más pequeño antes de tocar el más crítico.
La idea en una frase
Un puerto abstracto, un adaptador por proveedor, y una bandera remota que elige el adaptador caso de uso por caso de uso.
Lo primero y lo segundo es arquitectura hexagonal clásica. Lo tercero es lo que cambia el juego, y es lo que casi nunca se implementa: la bandera no es global, es por flujo de negocio.
En móvil eso importa el doble. Un webview embebido no se comporta igual en iOS que en Android — el ciclo de vida, el manejo del teclado y el evento de cierre difieren — así que un proveedor puede ser mejor para un flujo y peor para otro. Una bandera global te obliga a elegir un ganador único; una por flujo te deja quedarte con lo mejor de cada uno.
1. El puerto: el contrato de tu dominio
Empieza aquí. Es el archivo más importante de la arquitectura y el más corto.
// domain/browser-bridge.port.ts
export abstract class BrowserBridge {
abstract open(request: SessionRequest): Promise<void>;
abstract awaitOutcome(): Promise<SessionOutcome>;
abstract close(): Promise<void>;
}El resultado se expresa en el vocabulario de tu dominio, nunca en el del proveedor.
// domain/session.types.ts
export type FlowKey = 'checkout' | 'onboarding' | 'origination' | 'certificates';export type SessionOutcome =
| { readonly status: 'completed'; readonly reference: string }
| { readonly status: 'cancelled' }
| { readonly status: 'failed'; readonly reason: string };Prueba ácida: ¿puedes implementarlo entero con un mock en memoria en menos de cincuenta líneas? Si no, copiaste la superficie del SDK en vez de modelar una capacidad.
2. El adaptador: el único lugar contaminado
/ infrastructure/vendor-a.adapter.ts
import { InAppBrowser } from '@vendor-a/browser'; // único import del SDK en todo el repo@Injectable()
export class VendorABrowserBridge extends BrowserBridge {
readonly #outcomes = new Subject<SessionOutcome>(); async open({ url, locale }: SessionRequest): Promise<void> {
await InAppBrowser.openWebView({ url, headers: { 'Accept-Language': locale ?? 'es' } });
await InAppBrowser.addListener('urlChangeEvent', ({ url }) =>
this.#outcomes.next(this.#translate(url)),
);
} awaitOutcome(): Promise<SessionOutcome> {
return firstValueFrom(this.#outcomes);
} async close(): Promise<void> {
await InAppBrowser.removeAllListeners();
await InAppBrowser.close();
this.#outcomes.complete();
} /**
* Capa anticorrupción: traduce el vocabulario del proveedor al del dominio.
* Si mañana cambia "success" por "OK", se toca este método y nada más.
*/
#translate(rawUrl: string): SessionOutcome {
const params = new URL(rawUrl).searchParams;
switch (params.get('result')) {
case 'success': return { status: 'completed', reference: params.get('ref') ?? '' };
case 'user_abort': return { status: 'cancelled' };
default: return { status: 'failed', reason: params.get('error') ?? 'unknown' };
}
}
}
Fíjate en lo que este adaptador no hace: no escribe en el store global, no abre modales, no navega, no dispara analítica. Un adaptador que hace cualquiera de esas cosas dejó de ser infraestructura y se convirtió en una dependencia oculta que el próximo adaptador tendrá que replicar para no romper nada.
El segundo proveedor es un archivo nuevo que extiende el mismo puerto. No se toca ninguna de las clases que dependen de él.
3. La bandera por caso de uso
La configuración remota es entrada no confiable. Alguien va a escribir mal un valor en la consola un martes a las tres de la tarde, así que se valida como se valida el body de un request.
{
"browser.routing": {
"byFlow": {
"checkout": "vendor-b",
"certificates": "vendor-b",
"onboarding": "vendor-a",
"origination": "vendor-a"
},
"fallback": "vendor-a"
}
}Ese JSON es el plan de despliegue de la migración, escrito en un formato que se revierte en treinta segundos.
// application/browser-bridge.resolver.ts
export const PROVIDERS = ['vendor-a', 'vendor-b'] as const;
export type Provider = (typeof PROVIDERS)[number];const isProvider = (v: unknown): v is Provider =>
typeof v === 'string' && (PROVIDERS as readonly string[]).includes(v);@Injectable({ providedIn: 'root' })
export class BrowserBridgeResolver {
readonly #flags = inject(FeatureFlagsService); readonly #adapters: ReadonlyMap<Provider, BrowserBridge> = new Map([
['vendor-a', inject(VendorABrowserBridge)],
['vendor-b', inject(VendorBBrowserBridge)],
]); resolve(flow: FlowKey): { provider: Provider; bridge: BrowserBridge } {
const routing = this.#flags.getValue<ProviderRouting>('browser.routing', DEFAULT_ROUTING); const candidate = routing.byFlow?.[flow];
const provider = isProvider(candidate) ? candidate : routing.fallback; const bridge = this.#adapters.get(provider) ?? this.#adapters.get(DEFAULT_ROUTING.fallback)!;
return { provider, bridge };
}
}
Ese isProvider es lo que separa una bandera de una bomba: un typo en la consola degrada al fallback en vez de tumbar un flujo de negocio.
Get Diego Caceres’s stories in your inbox
Join Medium for free to get updates from this writer.
Y fíjate que resolve devuelve el proveedor junto al adaptador. Sin eso no puedes etiquetar tus métricas, y una bandera que cambia el comportamiento de producción sin dejar rastro en la observabilidad es un cambio del que no vas a aprender nada.
4. El caso de uso
Uno por flujo de negocio, no un servicio compartido.
@Injectable()
export class CheckoutSessionService {
readonly #resolver = inject(BrowserBridgeResolver);
readonly #analytics = inject(AnalyticsService);async run(url: string): Promise<SessionOutcome> {
const { provider, bridge } = this.#resolver.resolve('checkout');
await bridge.open({ url, flow: 'checkout' }); try {
const outcome = await bridge.awaitOutcome();
this.#analytics.track('checkout_session_end', { provider, status: outcome.status });
return outcome;
} finally {
await bridge.close(); // el cierre no depende del camino feliz
}
}
}
Al consumir el resultado, assertNever convierte al compilador en tu red de seguridad. Si mañana agregas un cuarto estado al dominio, el build se rompe hasta que lo manejes en todos lados:
const assertNever = (value: never): never => {
throw new Error(`Estado no manejado: ${JSON.stringify(value)}`);
};function handle(outcome: SessionOutcome): void {
switch (outcome.status) {
case 'completed': return showReceipt(outcome.reference); // TS sabe que 'reference' existe
case 'cancelled': return goBack();
case 'failed': return showError(outcome.reason);
default: return assertNever(outcome);
}
}La regla no es «evita el switch». Es: traduce el vocabulario del tercero en el borde y ramifica sobre tu propio tipo.
5. La factura que paga todo esto
it('should close the bridge even when the session fails', async () => {
// Arrange
const bridge = {
open: jest.fn().mockResolvedValue(undefined),
awaitOutcome: jest.fn().mockRejectedValue(new Error('sdk caído')),
close: jest.fn().mockResolvedValue(undefined),
};
const resolver = { resolve: jest.fn().mockReturnValue({ provider: 'vendor-a', bridge }) };
const service = new CheckoutSessionService(resolver as never, analyticsSpy);// Act
await expect(service.run('https://ejemplo.test/firma')).rejects.toThrow('sdk caído'); // Assert
expect(bridge.close).toHaveBeenCalledTimes(1);
});
Sin emulador, sin dispositivo, sin el SDK instalado. Tu lógica de negocio quedó testeable sin infraestructura, que en móvil vale doble: los tests que necesitan un dispositivo real son los que nadie corre.
Lo que compró, en números
Cinco lecciones, con su costo
👉🏻 El puerto pequeño se defiende activamente
Cada vez que alguien necesita «una cosita más del SDK», la presión es agregar un método al puerto. Cede tres veces y ya tienes una abstracción que nadie puede volver a implementar.
Costo: alguien tiene que decir que no, con argumento, en cada revisión de código.
👉🏻 El adaptador no puede filtrarse
Si toca el store global o abre modales, dejó de ser infraestructura.
Costo: lo descubrimos tarde y limpiarlo salió más caro que haberlo hecho bien.
👉🏻 Un caso de uso por flujo, no un servicio dios
El nuestro más grande llegó a 254 líneas, con un switch sobre un string crudo del proveedor y la limpieza duplicada en tres de sus cuatro ramas.
Costo: hoy es el archivo que nadie quiere tocar.
👉🏻 En móvil, los dos proveedores viajan en el mismo binario
Esta es la diferencia con la web: no puedes cargar un adaptador bajo demanda desde un CDN. Los dos plugins de Capacitor se compilan dentro del .ipa y el .aab, así que durante la convivencia pagas el peso de ambos, mantienes dos árboles de dependencias nativas y los dos tienen que sobrevivir cada actualización de iOS y Android.
Costo: unos megabytes de app y el doble de superficie ante cada cambio de plataforma.
👉🏻 La bandera temporal se vuelve permanente si nadie la cierra
Dieciocho meses después, el proveedor por defecto sigue siendo el viejo.
Costo: la bandera compró tiempo, y ese tiempo se gastó en no decidir.
Cuándo no usar este patrón
Tiene un costo fijo de entrada: un puerto, N adaptadores, una bandera y el doble de superficie de prueba mientras dure la convivencia. No lo pagues si:
- Hay un solo proveedor y no se ve un segundo. Un puerto con una implementación única es ceremonia. Usa el SDK directo y extrae el puerto el día que aparezca el segundo candidato: refactorizar hacia un puerto es barato, mantener una abstracción especulativa durante dos años no lo es.
- La integración es trivial. Si el tercero son tres llamadas HTTP, tu «puerto» es un servicio normal y ya está.
- Nadie va a cerrar la migración. Este es el filtro real. La bandera reduce el riesgo del cambio, no lo elimina: convierte un riesgo de despliegue en un riesgo de mantenimiento. Si tu organización no tiene la disciplina de borrar el adaptador viejo al terminar, no evitaste el big bang: lo pospusiste y ahora pagas dos proveedores.
Conclusión
El patrón funciona. Once flujos son conmutables en caliente, un solo archivo toca el SDK, un incidente del proveedor se resuelve con una configuración en vez de un release, y la lógica de negocio se prueba sin levantar nada.
Pero la conclusión útil no es «usa puertos y adaptadores». Es esta: la arquitectura te compra opciones, y las opciones caducan si nadie las ejerce. Si vas a implementar esto, agenda desde el primer día la fecha en que vas a borrar el adaptador viejo.








