By Madhuri Latha Gondi | Mobile Architecture & iOS Platform Engineering

In modern iOS applications, navigation is no longer a simple transition between screens. Users can enter an app through multiple entry points such as push notifications, universal links, marketing campaigns, and in-app actions.
As applications grow larger and more modular, navigation logic often becomes fragmented and difficult to maintain.
In many codebases, navigation begins with something simple like:
navigationController?.pushViewController(DetailsViewController(), animated: true)
While this works for small applications, large mobile systems quickly run into problems:
- tight coupling between feature modules
- inconsistent deep link handling
- navigation logic spread across many controllers
- poor testability
To address these challenges, I use an architectural approach called Descriptor-Driven Navigation Architecture (DDNA).
DDNA separates navigation intent from navigation implementation, allowing applications to scale navigation logic cleanly across modules.
What is Descriptor-Driven Navigation Architecture?
Instead of navigating directly to a screen, the application creates a navigation descriptor that represents the destination.
Example:
let descriptor = DeepLinkDescriptor(
path: "/profile/details",
parameters: ["userId": "12345"],
presentationMode: .push
)
This descriptor is then processed through a routing pipeline.
Entry Point
(UI / Push Notification / Universal Link)
↓
DeepLinkDescriptor
↓
NavigationManager
↓
DeepLinkMapper
↓
Feature ViewController
`This architecture makes navigation consistent across the application.
Step 1 — Define a Navigation Descriptor
The foundation of DDNA is the DeepLinkDescriptor object.
`
struct DeepLinkDescriptor {
enum PresentationMode {
case push
case modal
}
let path: String
let parameters: [String: String?]
let presentationMode: PresentationMode
}
`
This object represents navigation intent rather than a concrete screen.
Because it is independent of UIKit, descriptors can be created from:
- UI actions
- push notifications
- universal links
- background events
Step 2 — Implement a Navigation Manager
The NavigationManager coordinates routing.
protocol NavigationManaging {
func navigate(using descriptor: DeepLinkDescriptor)
}
Implementation:
`final class NavigationManager: NavigationManaging {
private let mapper: DeepLinkMapper
private weak var navigationContainer: NavigationContainer?
init(
mapper: DeepLinkMapper,
navigationContainer: NavigationContainer
) {
self.mapper = mapper
self.navigationContainer = navigationContainer
}
func navigate(using descriptor: DeepLinkDescriptor) {
guard let viewController = mapper.map(descriptor) else {
return
}
switch descriptor.presentationMode {
case .push:
navigationContainer?.push(viewController)
case .modal:
navigationContainer?.present(viewController)
}
}
}`
The NavigationManager orchestrates navigation without knowing the details of each feature module.
Step 3 — Map Descriptors to Screens
The DeepLinkMapper converts descriptors into actual destinations.
protocol DeepLinkMapper {
func map(_ descriptor: DeepLinkDescriptor) -> UIViewController?
}
Example implementation:
`
final class FeatureDeepLinkMapper: DeepLinkMapper {
func map(_ descriptor: DeepLinkDescriptor) -> UIViewController? {
switch descriptor.path {
case "/profile/details":
let userId = descriptor.parameters["userId"] ?? nil
return ProfileDetailsViewController(userId: userId)
default:
return nil
}
}
}
`
This isolates screen creation from navigation logic.
Step 4 — Introduce a Navigation Container
Instead of depending directly on UINavigationController, DDNA introduces a container abstraction.
`
protocol NavigationContainer: AnyObject {
func push(_ viewController: UIViewController)
func present(_ viewController: UIViewController)
}
`
Example implementation:
`
final class DefaultNavigationContainer: NavigationContainer {
weak var navigationController: UINavigationController?
func push(_ viewController: UIViewController) {
navigationController?.pushViewController(viewController, animated: true)
}
func present(_ viewController: UIViewController) {
navigationController?.present(viewController, animated: true)
}
}
`
This abstraction allows navigation to work with UIKit, SwiftUI bridges, or custom containers.
Step 5 — Assemble Using Dependency Injection
Instead of global singletons, dependencies are assembled at the application level.
`
final class NavigationManagerFactory {
static func make(
navigationController: UINavigationController
) -> NavigationManager {
let container = DefaultNavigationContainer()
container.navigationController = navigationController
let mapper = FeatureDeepLinkMapper()
return NavigationManager(
mapper: mapper,
navigationContainer: container
)
}
}
`
Dependency injection improves modularity and testability.
Example Usage
A feature module can trigger navigation using a descriptor.
`
let descriptor = DeepLinkDescriptor(
path: "/profile/details",
parameters: ["userId": "12345"],
presentationMode: .push
)
navigationManager.navigate(using: descriptor)
`
The feature does not need to know how the destination screen is constructed.
Supporting Universal Links
DDNA also simplifies deep link routing.
`
func descriptor(from url: URL) -> DeepLinkDescriptor {
let components = URLComponents(url: url, resolvingAgainstBaseURL: false)
var parameters: [String: String?] = [:]
components?.queryItems?.forEach {
parameters[$0.name] = $0.value
}
return DeepLinkDescriptor(
path: components?.path ?? "",
parameters: parameters,
presentationMode: .push
)
}
`
Now UI actions, push notifications, and universal links share the same navigation flow.
Benefits of DDNA
Descriptor-Driven Navigation Architecture provides several advantages:
- modular feature isolation
- centralized navigation logic
- consistent deep linking
- improved testability
- scalable mobile architecture This approach is particularly useful for enterprise mobile applications with multiple feature teams.
Final Thoughts
Navigation often becomes one of the most complex parts of a mobile architecture as applications grow.
By separating navigation intent from navigation execution, DDNA allows teams to build scalable routing systems while maintaining modular codebases.
Treating navigation as a platform capability rather than a controller detail enables mobile systems to scale more effectively as products evolve.
Top comments (0)