A comprehensive demonstration of modern SwiftUI navigation patterns combined with scope-based dependency injection architecture. This project showcases two complementary architectural approaches: Navigation Architecture (TabView, multiple NavigationStacks, programmatic navigation, and type-safe routing) and Scope Architecture (hierarchical dependency injection with protocol-based isolation). Together, they create a scalable foundation for building complex SwiftUI applications.
This sample project demonstrates two complementary architectural approaches that together solve complex challenges in building scalable SwiftUI applications:
Navigation Architecture (Wiki):
- TabView with Multiple NavigationStacks - Each tab has its own independent NavigationStack and navigation state
- Type-safe Navigation with enum-based destinations
- Programmatic Navigation using a centralized router
- Protocol-based Routing for feature isolation and testability
Scope Architecture (Wiki):
- Hierarchical Dependency Injection - Scope tree manages dependencies independently of view hierarchy
- Protocol-based Parent Contracts - Child scopes define required dependencies through protocols
- Feature Isolation - Each scope owns its domain-specific dependencies and state
- Testability - Easy mocking through scope hierarchies and protocol abstractions
The two architectures complement each other:
- Scope Architecture manages dependency injection and feature isolation
- Navigation Architecture handles routing and navigation state
- Scopes inject routers into views through the dependency tree
- Views trigger navigation through injected router protocols
- Router manages navigation state centrally while scopes provide the dependencies
This combination enables building complex applications with clear separation of concerns, excellent testability, and maintainable feature boundaries.
enum Destination: Hashable {
case home
case contactList
case conversation(Contact)
case contactDetail(Contact)
case profile_settings
case privacy_settings
}The Destination enum defines all possible navigation states in a type-safe manner. Each case can carry associated data (like Contact objects) and conforms to Hashable for use with NavigationStack.
Key Benefits:
- Compile-time safety for navigation destinations
- Associated values for passing data through navigation
- Hashable conformance enables use with NavigationStack's path binding
@Observable
final class Router: ContactRouter, ChatRouter, SettingsRouter {
var selectedTab: Tabs = .chats
var chatTabPath: [Destination] = []
var settingsTabPath: [Destination] = []
// Computed property for current navigation stack
var currentStack: [Destination] {
get {
switch selectedTab {
case .chats: return chatTabPath
case .settings: return settingsTabPath
}
}
set {
switch selectedTab {
case .chats: chatTabPath = newValue
case .settings: settingsTabPath = newValue
}
}
}
// Generic navigation methods
func goBack() {
if !currentStack.isEmpty {
currentStack.removeLast()
}
}
func popToRoot() {
currentStack.removeAll()
}
// ... feature-specific navigation methods for each tab ...
}The Router class serves as the single source of truth for navigation state. It manages a separate navigation path for each tab (e.g., chatTabPath, settingsTabPath) and provides methods for programmatic navigation. While the implementation of routing logic is centralized, the interfaces for routing are defined by features, improving feature isolation.
protocol ContactRouter {
func gotoConversation(recipient: Contact)
func gotoContactDetail(_ contact: Contact)
// Generic navigation methods
func goBack()
func popToRoot()
}Each feature defines its own routing protocol, allowing for:
- Feature Isolation: Features don't depend on concrete router implementation
- Testability: Easy to mock routers for unit tests
- Dependency Inversion: Features depend on abstractions, not concretions
Key Features:
- Single source of truth for navigation state
- Programmatic control over navigation stack
- Protocol conformance for feature-specific routing
struct ContentView: View {
@Environment(Router.self) var router
var body: some View {
@Bindable var router = router
TabView(selection: $router.selectedTab) {
Tab(
Tabs.chats.name,
systemImage: Tabs.chats.systemImageName,
value: Tabs.chats
) {
NavigationStack(path: $router.chatTabPath) {
HomeScreen(router: router)
.navigationDestination(for: Destination.self) { dest in
RouterView(router: router, destination: dest)
}
}
}
Tab(
Tabs.settings.name,
systemImage: Tabs.settings.systemImageName,
value: Tabs.settings
) {
NavigationStack(path: $router.settingsTabPath) {
SettingsHomeView(router: router)
.navigationDestination(for: Destination.self) { dest in
RouterView(router: router, destination: dest)
}
}
}
}
}
}The main navigation container uses a TabView with a separate NavigationStack for each tab, each bound to its own navigation path in the router. This allows each tab to maintain its own navigation history independently.
Key Benefits:
- Independent navigation stacks for each tab
- Type-safe navigation with
navigationDestination - Automatic state synchronization and retention when switching tabs
struct RouterView: View {
let router: Router
let destination: Destination
var body: some View {
switch destination {
case .home:
HomeScreen(router: router)
case .conversation(let recipient):
ConversationView(router: router, contact: recipient)
case .contactDetail(let contact):
ContactDetailView(router: router, contact: contact)
case .contactList:
ContactFeatureRootView(router: router)
case .profile_settings:
ProfileSettingsView()
case .privacy_settings:
PrivacySettingsView()
}
}
}The RouterView acts as a switch statement that maps destinations to their corresponding views, passing the router and any associated data.
Key Benefits:
- Clean separation of navigation logic from view logic
- Centralized view routing
- Easy to maintain and extend
Views can trigger navigation programmatically by calling router methods:
// Navigate to contact detail
Button("View Contact") {
router.gotoContactDetail(contact)
}
// Navigate to conversation
Button("Start Chat") {
router.gotoConversation(recipient: contact)
}
// Navigate to contacts list
Button("Add Contact") {
router.gotoContactsList()
}The Scope Architecture implements a hierarchical dependency injection system that manages application dependencies independently of the view hierarchy. This provides clean feature isolation, excellent testability, and scalable dependency management.
The application uses a flat tree structure of scopes where RootScope acts as the parent for all feature scopes:
RootScope
├── ContactScope
├── ChatScope
├── ConversationScope
└── SettingsScope
This flat structure eliminates nested dependency chains, simplifies navigation, and makes cross-scope communication easier. Each scope manages its own dependencies and receives them from RootScope.
A scope is a dependency container that manages resources for a specific feature or domain. Here's what constitutes a scope using ChatScope as an example:
final class ChatScope {
// Parent Reference - connection to parent scope
private let parent: Parent
// Dependencies from Parent - accessing shared resources
lazy var router: ChatRouter = parent.chatRouter
// Local Dependencies - feature-specific state and resources
lazy var chats: [Chat] = Chat.mock
// Initialization
init(parent: Parent) {
self.parent = parent
}
// View Factory Methods - centralized view creation
func chatFeatureRootview() -> some View {
ChatFeatureRootView(scope: self)
}
func chatListView() -> some View {
ChatListView(scope: self)
}
}
extension ChatScope {
// Parent Protocol - defines required dependencies
protocol Parent {
var chatRouter: ChatRouter { get }
// View Factory Methods - for cross-scope view creation
@ViewBuilder
func conversationView(contact: Contact) -> any View
}
}Key Components of a Scope:
- Parent Reference: Receives dependencies from parent through protocols
- Local Dependencies: Manages feature-specific state and resources
- View Factory Methods: Creates views with proper dependency injection
- Parent Protocol: Defines contract for required dependencies, including view factory delegation
- Lazy Loading: Dependencies are created only when needed
Note on Scope Design: Scopes should only be created when they manage state or coordinate dependencies. Simple stateless views don't require separate scopes.
The application organizes scopes in a hierarchical tree, where each scope can contain child scopes and receives dependencies from its parent:
// Parent scope providing shared dependencies in a flat structure
final class RootScope: ContactScope.Parent, ChatScope.Parent,
ConversationScope.Parent, SettingsScope.Parent {
lazy var rootRouter = Router()
lazy var dataModel = DataModel()
// Protocol implementations for child scopes
lazy var chatRouter: ChatRouter = rootRouter
lazy var contactRouter: ContactRouter = rootRouter
lazy var settingsRouter: SettingsRouter = rootRouter
// Child scopes - all are direct children (flat structure)
lazy var contactScope: Weak<ContactScope> = Weak({ ContactScope(parent: self) })
lazy var chatScope: Weak<ChatScope> = Weak({ ChatScope(parent: self) })
lazy var conversationScope: Weak<ConversationScope> = Weak({ ConversationScope(parent: self) })
lazy var settingsScope: Weak<SettingsScope> = Weak({ SettingsScope(parent: self) })
// View factory delegation for cross-scope view creation
@ViewBuilder
func conversationView(contact: Contact) -> any View {
conversationScope.value.conversationView(contact: contact)
}
}Flat Structure Benefits:
- Simplified Dependencies: All feature scopes access RootScope directly
- Easier Navigation: No nested scope chains to traverse
- Feature Isolation: Each scope manages its own domain independently
- Lazy Creation: Scopes are created only when needed
- Clean Boundaries: Clear separation between different app areas
- View Factory Delegation: RootScope can create views from any scope for cross-scope access
All child scope references use a Weak<> wrapper that provides lazy instantiation with weak reference management:
// Consistent pattern for all child scopes
lazy var childScope: Weak<ChildScope> = Weak({ ChildScope(parent: self) })
// Access child scopes via .value property
childScope.value.someMethod()The Weak<T> utility class offers several benefits:
Memory Efficiency:
- Holds weak references to child scopes to prevent unnecessary memory usage
- Allows child scopes to be deallocated when not actively used
- Recreates instances automatically when needed using the provider closure
Consistent Pattern:
- All child scope references follow the same
Weak<>wrapper pattern - Uniform access via
.valueproperty across the codebase - Lazy instantiation - scopes created only when first accessed
Implementation:
class Weak<T: AnyObject> {
private weak var _value: T?
private let provider: () -> T
init(_ provider: @escaping () -> T) {
self.provider = provider
}
var value: T {
if let value = _value {
return value
}
let newValue = provider()
_value = newValue
return newValue
}
}This approach ensures consistent memory management across all child scopes while maintaining the lazy instantiation benefits that are crucial for performance in large scope hierarchies.
struct ChatFeatureRootView: View {
let scope: ChatScope
var body: some View {
VStack {
scope.chatListView() // Using scope as view factory
}
.toolbar {
ToolbarItem(placement: .automatic) {
Button(action: {
scope.router.gotoContactList() // Using injected router
}) {
Image(systemName: "plus.circle.fill")
}
}
}
}
}
// Example of direct view usage (no scope needed for simple views)
struct ChatListView: View {
let scope: ChatScope
var body: some View {
List(scope.chats) { chat in
ChatListItemView(chat: chat) // Simple view, no scope needed
}
}
}Views receive their dependencies through scopes, enabling clean separation and easy testing.
#if DEBUG
extension ChatScope {
static var MOCK: ChatScope = ChatScope(parent: RootScope.MOCK)
}
extension RootScope {
static var MOCK: RootScope = RootScope()
}
#endifThe scope architecture enables comprehensive testing through:
- Reusable RootScope.MOCK: All scopes can reuse the same root mock, reducing boilerplate
- Isolated Testing: Each scope can be tested independently
- Protocol Abstractions: Mock routers and dependencies through protocol conformance
- Consistent Pattern: Uppercase MOCK naming convention for all test instances
The scope architecture integrates seamlessly with the navigation architecture:
- Parent scopes create and provide access to the central
Router - Feature scopes receive router protocols through parent dependencies
- Views trigger navigation through scope-injected router protocols
- Router manages navigation state centrally while scopes provide structure
This integration ensures that dependency management and navigation concerns remain properly separated while working together effectively.
This sample demonstrates an opinionated approach to SwiftUI navigation that prioritizes type safety, testability, and scalability. For more details on the design philosophy, see the accompanying blog post: Building Scalable SwiftUI Navigation.


