Design a deep-linking system for a large app with many feature modules
Route a URI to the right screen across dozens of independently owned modules without a central switch statement that everyone has to touch.
LEARN BY DOINGPut your intuition to work3 steps
Design a deep-linking system for a large app with many feature modules
Use the lesson’s examples and explanation to check your idea.
Personal reflection. Nothing is sent to an AI service.#Clarifying the requirements
Deep linking sounds like a routing problem, and at small scale it is — a handful of if/else branches on a URL path work fine for an app with five screens. The interesting system-design version of this question is what happens at the scale the prompt describes: dozens of feature modules, each owned by a different team, each wanting to register its own links, in a codebase where the whole point of modularization was that a feature team shouldn't need to touch a shared file (let alone a shared build target) to ship. So I'd frame the real requirements as: (1) any module can register the links it owns without editing code outside its own module; (2) links must resolve correctly whether the app is cold-started, backgrounded, or already showing an unrelated screen; (3) unauthenticated users hitting a link that needs auth get sent through login and then land where they originally intended, not dropped at a home screen; (4) the system needs to support both universal links / App Links (https URLs that also work as normal web links when the app isn't installed) and a custom URI scheme for internal use (push notifications, in-app navigation, marketing); (5) it should degrade gracefully — an unrecognized or malformed link shouldn't crash, it should fall back to a sane default.
#The core architectural bet: a declarative route registry, not a switch statement
The single decision that makes this scale across dozens of teams is inverting who depends on whom: instead of one central router module that imports every feature module and switches on path prefixes (which means the router module depends on all fifty features, and every new feature requires a PR against a shared file that everyone is touching, guaranteeing merge conflicts and ownership confusion), each feature module registers its own routes into a shared registry at app startup, and the router only depends on the registry's interface, never on any specific feature. This is dependency inversion applied to navigation: the core module defines a RouteHandler contract and a registration API; features depend on that tiny core interface; the core module never depends on any feature.
#The registry and route contract
The registry is a plain, feature-agnostic data structure: a list of (pattern, handler) pairs, where a pattern is a path template like /profile/{userId} or /article/{slug} and the handler knows how to turn matched path/query parameters into a concrete navigation action within that module. Crucially, the handler returns an abstract navigation intent (e.g., "push this destination with these arguments") rather than the core module needing to know about the feature's actual screen classes — this keeps the dependency arrow pointing the right way. Registration happens at app startup, typically via each feature module's own initializer, so adding a new deep link is entirely a change inside that module's own files.
// Core module — knows nothing about any specific feature.data class DeepLinkMatch(val params: Map<String, String>, val query: Map<String, String>)interface RouteHandler {val pattern: String // e.g. "/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)}}// Inside the content feature module, registered at that module's init: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"]))}
#Handling cold start, warm state, and backstack construction
A deep link can arrive in three very different app states, and treating them uniformly is a common source of bugs: cold start (the app process doesn't exist yet — the OS launches it with the link as launch intent/URL), background-to-foreground (the process exists but the relevant screen isn't showing), and already-foreground (the app is open on some unrelated screen and a push notification or universal link arrives). In all three cases, resolving the link to a destination is the same registry lookup — but constructing the right backstack differs: a cold start into /checkout/order-123 shouldn't leave the user with no way to navigate back to the home tab, so the navigator needs to synthesize a sensible backstack underneath the deep-linked screen (e.g., push home → push the relevant tab root → push the destination), rather than just presenting the destination in isolation. I'd centralize this "synthetic backstack" logic in the core navigator, driven by metadata each RouteHandler can optionally declare (its parent route/tab), so features don't each reinvent backstack construction.
#Auth-gating and deferred deep links
A link like /checkout/order-123 needs the user logged in; if they're not, the naive move — bounce to login and stop — loses the original intent and the user lands on a generic home screen after authenticating, which is a bad enough experience that people notice. The resolver checks requiresAuth before handing off to the navigator, and if the user isn't authenticated, it serializes the pending NavigationIntent (or just the original URI, which is simpler and more robust to app version changes) and stores it, then routes to login. On successful login, the app checks for a stored pending deep link and replays it through the exact same resolver, so "log in, then land where you meant to go" is a property of the auth flow rather than something every feature has to implement for itself.
#Universal links vs. custom scheme, and graceful fallback
Universal Links (iOS) / App Links (Android) — real https:// URLs that open the app if installed and fall back to the website if not — are what should ship in marketing emails, SMS, and anywhere outside the app, because a custom scheme (myapp://) does nothing useful when the app isn't installed and looks unprofessional in a shared link preview. The custom scheme still earns its place for links generated inside the ecosystem where you control both ends (push notification payloads, in-app "share" actions between two features, QR codes tied to a physical kiosk) where you don't need web fallback and want a guaranteed-app-only path. Both schemes funnel into the same registry and resolver — the only difference is at the OS-level registration (an assetlinks.json/apple-app-site-association file proving domain ownership, versus an intent-filter/CFBundleURLTypes entry for the custom scheme) — so the app's internal routing logic doesn't fork based on which one was used.
#Tradeoffs
The registry pattern's cost is indirection: tracing "what happens when this link is tapped" now requires knowing the pattern lives in some feature module's registration code rather than reading straight down a switch statement, which is a real discoverability cost for newcomers — I'd mitigate it with a debug-only screen that lists every registered pattern and lets you fire a test link against it. The other tradeoff is that pattern-matching by string path is inherently less type-safe than direct function calls; some teams solve this with codegen (annotation processors that generate typed navigation calls from route declarations), which I'd consider once the number of modules and cross-feature navigation calls justifies the build-time cost. For the scale described in the question — dozens of independently owned modules — I'd stand behind the registry-plus-dependency-inversion shape as the right tradeoff: it costs a bit of indirection and a debug tool to keep discoverability sane, in exchange for genuinely decoupled ownership, which is the entire reason the app was modularized in the first place.