Diseña un sistema de deep-linking para una app grande con muchos módulos de features
Enruta una URI a la pantalla correcta a través de decenas de módulos con dueños independientes, sin un switch central que todos tengan que tocar.
APRENDE HACIENDOPon a prueba tu intuición3 pasos
Diseña un sistema de deep-linking para una app grande con muchos módulos de features
Usa los ejemplos y la explicación de la lección para comprobar tu idea.
Reflexión personal. No se envía a ningún servicio de IA.#Aclarando los requisitos
El deep linking suena como un problema de enrutamiento, y a pequeña escala lo es —un puñado de ramas if/else sobre una ruta de URL funcionan bien para una app con cinco pantallas. La versión interesante de este problema como diseño de sistemas es qué pasa a la escala que describe el enunciado: decenas de módulos de features, cada uno propiedad de un equipo distinto, cada uno queriendo registrar sus propios links, en una base de código donde todo el sentido de la modularización era que un equipo de feature no debería necesitar tocar un archivo compartido (y mucho menos un build target compartido) para poder lanzar. Así que enmarcaría los requisitos reales así: (1) cualquier módulo puede registrar los links que le pertenecen sin editar código fuera de su propio módulo; (2) los links deben resolver correctamente ya sea que la app arranque en frío, esté en segundo plano, o ya esté mostrando una pantalla no relacionada; (3) los usuarios no autenticados que llegan a un link que requiere auth deben pasar por login y luego aterrizar donde originalmente pretendían ir, no quedar tirados en una pantalla de inicio; (4) el sistema necesita soportar tanto universal links / App Links (URLs https:// reales que también funcionan como links web normales cuando la app no está instalada) como un esquema de URI personalizado para uso interno (notificaciones push, navegación in-app, marketing); (5) debe degradar con gracia — un link no reconocido o mal formado no debería crashear, debería caer a un valor por defecto razonable.
#La apuesta arquitectónica central: un registro declarativo de rutas, no un switch
La decisión única que hace que esto escale entre decenas de equipos es invertir quién depende de quién: en lugar de un módulo router central que hace import de cada módulo de feature y decide con un switch sobre prefijos de ruta (lo cual significa que el módulo router depende de las cincuenta features, y cada feature nueva requiere un PR contra un archivo compartido que todo el mundo está tocando, garantizando conflictos de merge y confusión de ownership), cada módulo de feature registra sus propias rutas en un registro compartido al arrancar la app, y el router solo depende de la interfaz del registro, nunca de ninguna feature específica. Esto es inversión de dependencias aplicada a la navegación: el módulo core define un contrato RouteHandler y una API de registro; las features dependen de esa pequeña interfaz core; el módulo core nunca depende de ninguna feature.
#El registro y el contrato de rutas
El registro es una estructura de datos simple y agnóstica de features: una lista de pares (patrón, handler), donde un patrón es una plantilla de ruta como /profile/{userId} o /article/{slug} y el handler sabe cómo convertir los parámetros de ruta/query que hicieron match en una acción de navegación concreta dentro de ese módulo. Es crucial que el handler devuelva una intención de navegación abstracta (por ejemplo, "empuja este destino con estos argumentos") en lugar de que el módulo core necesite conocer las clases de pantalla reales de la feature — esto mantiene la flecha de dependencia apuntando en la dirección correcta. El registro ocurre al arrancar la app, típicamente vía el propio inicializador de cada módulo de feature, así que agregar un nuevo deep link es enteramente un cambio dentro de los propios archivos de ese módulo.
// Módulo core — no sabe nada de ninguna feature específica.data class DeepLinkMatch(val params: Map<String, String>, val query: Map<String, String>)interface RouteHandler {val pattern: String // ej. "/article/{slug}"val requiresAuth: Booleanfun buildIntent(match: DeepLinkMatch): NavigationIntent}sealed interface NavigationIntent {data class PushScreen(val route: String, val args: Bundle) : NavigationIntentdata class ReplaceRoot(val route: String, val args: Bundle) : NavigationIntentobject Unresolved : NavigationIntent}object DeepLinkRegistry {private val handlers = mutableListOf<RouteHandler>()fun register(handler: RouteHandler) { handlers += handler }fun resolve(uri: Uri): NavigationIntent {val handler = handlers.firstOrNull { matches(it.pattern, uri.path) }?: return NavigationIntent.Unresolvedval match = extractParams(handler.pattern, uri)return handler.buildIntent(match)}}// Dentro del módulo de content, registrado en el init de ese módulo:class ArticleRouteHandler : RouteHandler {override val pattern = "/article/{slug}"override val requiresAuth = falseoverride fun buildIntent(match: DeepLinkMatch) =NavigationIntent.PushScreen("article_detail", bundleOf("slug" to match.params["slug"]))}
#Manejando el arranque en frío, el estado tibio y la construcción del backstack
Un deep link puede llegar en tres estados de app muy distintos, y tratarlos de forma uniforme es una fuente común de bugs: arranque en frío (el proceso de la app aún no existe — el SO la lanza con el link como intent/URL de arranque), de segundo plano a primer plano (el proceso existe pero la pantalla relevante no se está mostrando), y ya en primer plano (la app está abierta en alguna pantalla no relacionada y llega una notificación push o un universal link). En los tres casos, resolver el link a un destino es la misma búsqueda en el registro — pero construir el backstack correcto difiere: un arranque en frío hacia /checkout/order-123 no debería dejar al usuario sin forma de volver al tab de inicio, así que el navigator necesita sintetizar un backstack razonable debajo de la pantalla del deep link (por ejemplo, empujar home → empujar la raíz del tab relevante → empujar el destino), en lugar de simplemente presentar el destino de forma aislada. Centralizaría esta lógica de "backstack sintético" en el navigator del core, guiada por metadata que cada RouteHandler puede declarar opcionalmente (su ruta/tab padre), para que las features no reinventen cada una la construcción del backstack.
#Auth-gating y deep links diferidos
Un link como /checkout/order-123 necesita que el usuario esté logueado; si no lo está, el movimiento ingenuo —rebotar a login y detenerse ahí— pierde la intención original y el usuario aterriza en una pantalla de inicio genérica después de autenticarse, lo cual es una experiencia lo suficientemente mala como para que la gente lo note. El resolvedor revisa requiresAuth antes de entregarle el control al navigator, y si el usuario no está autenticado, serializa el NavigationIntent pendiente (o simplemente la URI original, que es más simple y más robusto ante cambios de versión de la app) y lo guarda, luego enruta a login. Al tener éxito el login, la app revisa si hay un deep link pendiente guardado y lo reproduce a través del mismo resolvedor exacto, así "inicia sesión, y luego aterriza donde pretendías ir" es una propiedad del flujo de auth en lugar de algo que cada feature tenga que implementar por su cuenta.
#Universal links vs. esquema personalizado, y degradación con gracia
Los Universal Links (iOS) / App Links (Android) —URLs https:// reales que abren la app si está instalada y caen al sitio web si no— son lo que debería usarse en emails de marketing, SMS, y en cualquier lugar fuera de la app, porque un esquema personalizado (myapp://) no sirve de nada cuando la app no está instalada y se ve poco profesional en la vista previa de un link compartido. El esquema personalizado sigue ganándose su lugar para links generados dentro del ecosistema donde controlas ambos extremos (payloads de notificaciones push, acciones de "compartir" in-app entre dos features, códigos QR ligados a un kiosco físico) donde no necesitas fallback web y quieres un camino garantizado solo-para-la-app. Ambos esquemas convergen en el mismo registro y resolvedor —la única diferencia está en el registro a nivel de SO (un archivo assetlinks.json/apple-app-site-association que prueba propiedad del dominio, versus una entrada intent-filter/CFBundleURLTypes para el esquema personalizado)— así que la lógica interna de enrutamiento de la app no se bifurca según cuál se haya usado.
#Tradeoffs
El costo del patrón de registro es la indirección: rastrear "qué pasa cuando se toca este link" ahora requiere saber que el patrón vive en el código de registro de algún módulo de feature en lugar de leer directamente un switch, lo cual es un costo real de descubribilidad para gente nueva — lo mitigaría con una pantalla solo-de-debug que liste cada patrón registrado y permita disparar un link de prueba contra él. El otro tradeoff es que el matching de patrones por string de ruta es inherentemente menos type-safe que las llamadas a función directas; algunos equipos resuelven esto con codegen (procesadores de anotaciones que generan llamadas de navegación tipadas a partir de declaraciones de ruta), lo cual consideraría una vez que el número de módulos y llamadas de navegación entre features justifique el costo en tiempo de build. Para la escala descrita en la pregunta —decenas de módulos con dueños independientes— respaldaría la forma de registro-más-inversión-de-dependencias como el tradeoff correcto: cuesta algo de indirección y una herramienta de debug para mantener sana la descubribilidad, a cambio de un ownership genuinamente desacoplado, que es toda la razón por la que la app se modularizó en primer lugar.