← Deep linking

English Cymraeg

Case study ·

Where URL handling lives

Async Digital Ltd Cardiff, UK

Abstract

Every library you adopt rewrites some of your code in its image. The containment test asks how much: can the consumer answer “where does your URL handling live?” with a folder name and a low percentage? Deep linking is an inherently invasive concern, with URLs landing into arbitrary view state, so the answer matters more than usual. This case study measures the answer for one consumer of Deep Link Kit: one folder holding 17.5% of the app’s lines, three documented imports outside it, a domain layer with zero deep-link symbols, and an abstraction footprint of one protocol conformance, one subclass, and one method override. The most instructive number is the one that looks worst: 24 direct view reads of coordinator state that turn out to be the natural cost of shared ephemeral state in any architecture, and exactly what buys a tap and a URL identical behaviour.

The test of any infrastructure library is whether the consumer can still point at it. Ask where the URL handling lives. If the answer is a folder name and a low percentage, the library is doing its job. This piece is that answer, measured, for Deep Link Kit’s demo consumer.


§1·Containment

The question that measures a library

Adopt any dependency and some of your code starts to look like its code. That is the deal, and it is usually a fair one. The question worth asking before signing is how much, and where. A logging library that wants one import and one call per file is cheap. A navigation library that wants a delegate in every screen is expensive in a way no feature list admits.

Deep linking sits at the expensive end by nature. A URL can arrive at any moment and land in any view state, so handling it properly touches navigation, sheet presentation, scroll position, search, and whatever else the app considers state. The lazy implementation smears that handling across every screen it can reach. Which is why I hold a deep-linking library to a harder version of the containment test: the consumer should be able to answer “where does your URL handling live?” with a folder name and a low percentage.

The numbers in this piece come from a demo messaging app built as the kit’s consumer, the same app the rest of this series measures. What follows is the audit I would want to read before adopting anyone else’s kit: the folder, the boundary, what the consumer writes, and the coupling that looks like leakage until you trace where it comes from.

§2·Footprint

One folder, and an honest number

The consumer’s answer is a folder named DeepLink/ and 17.5% of the app’s lines. Everything the kit touches lives in that folder: 1,206 lines across 11 files. The rest of the app is allowed to forget the kit exists, with three exceptions the next section walks through.

A containment number only means something against the complexity it contains. This is not a thin URL-to-screen map. The folder holds an 18-intent URL surface with state-aware resolution, in-flight cancellation, last-writer-wins arbitration when URLs race, no-op elision, and full tracing. That 17.5% is an honest number for a genuinely complex feature, not a sign of a kit leaking. Compressing all of that behaviour into under a fifth of the codebase, with the rest of the app uninvolved, is the kit doing its job.

Fig 1 Where the lines live. The DeepLink/ folder is 17.5% of the codebase; the rest of the app is free to forget the kit exists.
§3·Boundary

Three imports and a silent domain layer

Exactly three files outside the folder import the kit: the app entry point, one destination view, and one sheet view. The entry point wires the coordinator into the environment and hands incoming URLs over. The destination view receives a handoff when a deep-link push lands, so it can finish the journey the URL started. The sheet view asks the navigation facade for one explicit forward step. Each import is deliberate, documented, and small.

The domain layer, the part of the app that knows what a conversation, a message, a person, and an attachment actually are, contains zero deep-link symbols. No import, no reference, no type leakage. The line sits where it should: the kit owns how you get somewhere, and the domain owns what is there when you arrive.

Fig 2 One folder, three documented imports, and a domain layer the kit never reaches.

Boundaries like this earn their keep when things change. When a breaking release of the kit reshaped its coordinator base, the consumer absorbed the entire migration inside one file. Change on one side of a real boundary stays on that side.

§4·Abstraction

What the consumer actually writes

Adopting the kit costs the consumer three constructs.

The detail worth pausing on is what the override does not contain. Structural navigation (push, present, pop, dismiss) never appears in the consumer’s switch, because the kit dispatches those steps itself. The consumer describes its app; the kit moves it.

The step lists read like documentation of the URL surface. Opening a conversation expands to a shared reset prefix, then .effect(.scrollToConversationRow(id)), then .nav(.push(.conversation(id))). Every URL declares its starting-state requirements once, and the kit’s no-op elision collapses the prefix to whatever the current state actually needs. The procedural alternative, a trail of dismiss-then-pop-then-push calls scattered across case arms, is the code most apps end up with. The declarative shape is the difference between describing a destination and giving directions.

§5·Parity

The pollution that isn’t

The most interesting number in any containment audit is the one that looks like a violation. Here it is this: views read coordinator state directly 24 times, through eight properties with names like expandedAvatar, activeSearchQuery, pendingAttachmentExpansion, and lastReturnedConversationId. On first sight, that reads as the kit leaking into the view layer.

Trace the writers and the picture inverts. Every one of those properties has a non-deep-link writer: an avatar tap, text arriving in the search field, a natural pop back from a thread. The same state would exist in a version of this app with no deep linking at all, just spread across different owners. The kit didn’t invent the state. It colocated it on the coordinator, so that one type is responsible for what the UI is doing right now, and both tap entries and URL entries write through the same surface.

That colocation is what buys behavioural parity. The tap entry point for settings opens a settings URL and goes through the same resolver the URL would.

No drift between tap and URL, because there is no second code path to drift.

The full coupling picture, counted across the view layer: 48 callsites land on the deep-link-relevant surface of the coordinator. Of those, 19 are tap entry points, which would exist in any architecture. Three are deliberate URL-routed opens (settings, search, and profile), the parity above made concrete. Two touch the navigation facade directly. The remaining 24 are the state reads, which turn out to be the natural cost of shared ephemeral state in any architecture, not a kit-imposed cost.

§6·Limits

What the design pays for

No design is free, and a containment audit that only reports the wins isn’t an audit. Three costs are worth naming.

Caveat

The coordinator owns eight pieces of ephemeral UI state. At this scale that is manageable; at around 12 it would start to feel like a god object, and splitting navigation state from ephemeral UI state into separate observables would be the natural next move.

The function that expands intents into steps is a single switch. It reads well at the current size of the URL surface; past roughly 25 intents it would want breaking into helpers per intent family.

Determinism has a test bill. Deep linking accounts for more than half of the consumer’s test suite by line count, 57% of it. Races, cancellation, state-aware resolution, and URL parsing all need coverage, so the weight is justifiable, but a library that handed its consumers fewer load-bearing concurrency surfaces would let them write fewer tests.

The reading I take from the audit: a library passes the containment test when the consumer can answer the question this piece opened with, a folder name and a low percentage. Here the answer is DeepLink/ and 17.5%, with a clean domain layer, three documented imports, and view-layer coupling that reduces to the ordinary cost of shared state. The kit earns its abstraction.

Containment settles where URL handling lives. It says nothing yet about the harder question a URL faces when it arrives: what should this link mean, given the state the app is already in? The same short URL should present settings as a sheet from the root and push it from inside a thread. That decision, and the frozen snapshot of app state that makes it deterministic, is the subject of the next piece in this series. The full map lives on the deep-linking hub.