The App module and coordinators
In the first post of the series I walked through the eight project structure, the manifests and the build measurements. In this post we move up to the box at the top of the graph, the App module. App has exactly one job; wiring the other seven modules together. It decides which flow to show, sets up the tab bar and listens for the “logged in” and “logged out” events coming from the modules. It has no screens of its own and no business logic of its own. In this post I’ll try to explain how that wiring is set up, the split between coordinators and routers, the session protocols and why the app icon lives here.
The App project: Project.swift and appSettings
The App manifest depends on all seven modules and has no test target:
Projects/App/Project.swift
import ProjectDescription
import ProjectDescriptionHelpers
let project = Project.app(
name: "App",
bundleIdSuffix: "app",
hasTests: false,
dependencies: [
.module("DesignSystem"),
.module("Components"),
.module("PreLoginModule"),
.module("HomeModule"),
.module("ExploreModule"),
.module("BookmarksModule"),
.module("ProfileModule")
]
)There is no test target because App only does wiring. The logic worth testing lives in the modules, we’ll get to those in the upcoming posts.
There is no Info.plist file in the repo. The target factory generates the plist with .extendingDefault(with:) so the plist is part of the manifest as well:
Tuist/ProjectDescriptionHelpers/Target+Factory.swift
infoPlist: .extendingDefault(with: [
"CFBundleDisplayName": "Tuist Architecture",
"UILaunchStoryboardName": "LaunchScreen",
"UISupportedInterfaceOrientations": [
"UIInterfaceOrientationPortrait",
"UIInterfaceOrientationPortraitUpsideDown",
"UIInterfaceOrientationLandscapeLeft",
"UIInterfaceOrientationLandscapeRight"
],
"UIApplicationSceneManifest": [
"UIApplicationSupportsMultipleScenes": false,
"UISceneConfigurations": [
"UIWindowSceneSessionRoleApplication": [
[
"UISceneConfigurationName": "Default Configuration",
"UISceneDelegateClassName": "$(PRODUCT_MODULE_NAME).SceneDelegate"
]
]
]
]
]),I went this way because I want the project definition and the rest of the settings and permissions managed from a single source. There is no hand edited XML in the repo. For build settings there is appSettings, which adds three things on top of the module settings:
Tuist/ProjectDescriptionHelpers/Target+Factory.swift
static var appSettings: Settings {
.settings(
base: baseSettings
.merging(mainActorSettings) { _, new in new }
.merging([
"ASSETCATALOG_COMPILER_APPICON_NAME": "AppIcon",
"ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME": "AccentColor",
"GENERATE_INFOPLIST_FILE": "YES"
]) { _, new in new }
)
}AppDelegate and SceneDelegate
AppDelegate is two methods. The app is scene based so deciding what goes on screen doesn't happen here:
import UIKit
@main
final class AppDelegate: UIResponder, UIApplicationDelegate {
func application(_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
true
}
func application(_ application: UIApplication,
configurationForConnecting connectingSceneSession: UISceneSession,
options: UIScene.ConnectionOptions) -> UISceneConfiguration {
UISceneConfiguration(
name: "Default Configuration",
sessionRole: connectingSceneSession.role
)
}
}SceneDelegate creates the window and starts the AppCoordinator:
final class SceneDelegate: UIResponder, UIWindowSceneDelegate {
var window: UIWindow?
private var appCoordinator: AppCoordinator?
func scene(_ scene: UIScene,
willConnectTo session: UISceneSession,
options connectionOptions: UIScene.ConnectionOptions) {
guard let windowScene = scene as? UIWindowScene else {
return
}
let window = UIWindow(windowScene: windowScene)
let coordinator = AppCoordinator(window: window,
sessionStore: UserDefaultsSessionStore())
self.window = window
appCoordinator = coordinator
coordinator.start()
}
}AppCoordinator: which flow gets shown?
The app has two flows, the screens before login and the tab bar with its screens after login. AppCoordinator picks between the two with sessionStore.isLoggedIn:
final class AppCoordinator {
private let window: UIWindow
private let sessionStore: any AppSessionStoring
private var flowCoordinator: (any Coordinator)?
/// Creates the application coordinator
///
/// - Parameters:
/// - window: Window whose root view controller is driven by the coordinator
/// - sessionStore: Session storage deciding which flow is shown
init(window: UIWindow,
sessionStore: any AppSessionStoring) {
self.window = window
self.sessionStore = sessionStore
}
/// Shows the flow matching the persisted session state and makes the window visible
func start() {
if sessionStore.isLoggedIn {
showMainTabBar(animated: false)
} else {
showPreLogin(animated: false)
}
window.makeKeyAndVisible()
}
// ...
/// Starts the given flow and installs it as the window root
///
/// - Parameters:
/// - coordinator: Flow coordinator to install
/// - animated: Whether the root replacement is cross dissolved
private func setFlow(_ coordinator: any Coordinator, animated: Bool) {
coordinator.start()
flowCoordinator = coordinator
window.rootViewController = coordinator.rootViewController
guard animated else {
return
}
UIView.transition(with: window,
duration: Constant.transitionDuration,
options: .transitionCrossDissolve,
animations: nil)
}
}setFlow installs the new flow's root view controller as the window root and replaces the flowCoordinator reference. Since there is a single reference, the old flow's coordinator loses its last strong reference at that moment. The transition is a 0.3 second cross dissolve. At launch it's called with animated: false so the first screen appears without animation. The Coordinator protocol every flow conforms to has two requirements:
Projects/App/Sources/Coordinator/Coordinator.swift
protocol Coordinator: AnyObject {
var rootViewController: UIViewController { get }
/// Builds the initial screen of the flow
func start()
}On logout the same path runs in reverse, the Welcome screen replaces the tab bar:
The delegate chain: how does logout travel from Profile to App?
The logout button lives inside ProfileModule and the code that switches the flow lives in App. Three delegates carry the message between the two. The first link is the protocol the module exposes:
Projects/ProfileModule/Sources/ProfileFlowDelegate.swift
/// Receives the events that leave the profile tab
public protocol ProfileFlowDelegate: AnyObject {
/// Notifies that the user logged out and the logged in flow is complete
func profileDidLogout()
}ProfileCoordinator in App implements this protocol and passes the event one layer up:
Projects/App/Sources/Coordinator/ProfileCoordinator.swift
extension ProfileCoordinator: ProfileFlowDelegate {
/// Forwards the logout to the delegate
func profileDidLogout() {
delegate?.profileCoordinatorDidLogout(self)
}
}MainTabBarCoordinator carries the same information one more layer up to AppCoordinator which then calls showPreLogin(animated: true). All three links are weak var delegate. The module doesn't know App, it only knows a protocol it can use to say "I logged out". The same chain exists on the login side between PreLoginFlowDelegate and PreLoginCoordinator. There is one difference there. The entry builder of PreLoginModule asks for a PreLoginDependencies value, so the coordinator puts itself into that value as the flow delegate:
Projects/App/Sources/Coordinator/PreLoginCoordinator.swift
/// Builds the welcome screen as the root of the pre-login navigation stack
func start() {
let dependencies = PreLoginDependencies(sessionStore: sessionStore, flowDelegate: self)
let welcome = WelcomeBuilder.build(dependencies: dependencies)
navigationController.setViewControllers([welcome], animated: false)
}Everything the module receives from App is inside this single struct. The session store and the delegate that will hear the events leaving the flow are both here. The Login and Register screens inside the module receive the same value through the router and the App doesn’t even know they exist.
MainTabBarCoordinator and MainTab
The tab bar flow creates and holds four tab coordinators. Every tab has its own navigation controller; the ExampleNavigationController from DesignSystem:
Get Çağatay Eğilmez’s stories in your inbox
Join Medium for free to get updates from this writer.
Projects/App/Sources/Coordinator/MainTabBarCoordinator.swift
/// Starts every tab coordinator and installs their roots into the tab bar
func start() {
let profileCoordinator = ProfileCoordinator(sessionStore: sessionStore)
profileCoordinator.delegate = self
tabCoordinators = [
HomeCoordinator(),
ExploreCoordinator(),
BookmarksCoordinator(),
profileCoordinator
]
tabCoordinators.forEach { $0.start() }
tabBarController.setViewControllers(tabCoordinators.map(\.rootViewController), animated: false)
}I gave every tab its own navigation controller so the stacks stay independent and a push in one tab doesn’t affect another. In the sample project three of the tab coordinators (Home, Explore and Bookmarks) are identical, only the builder they call changes. Here is HomeCoordinator as an example:
Projects/App/Sources/Coordinator/HomeCoordinator.swift
final class HomeCoordinator: Coordinator {
var rootViewController: UIViewController {
navigationController
}
private let navigationController = ExampleNavigationController()
/// Builds the home screen as the root of the home tab navigation stack
func start() {
navigationController.tabBarItem = MainTab.home.tabBarItem
navigationController.setViewControllers([HomeBuilder.build()], animated: false)
}
}HomeBuilder.build() is the only public entry point of HomeModule. The coordinator calls it and puts the returned view controller at the root of the navigation stack and assigns the tab item. That's all. The Profile one does one more step; it hands the builder a ProfileDependencies value carrying the session store and the coordinator itself as the flow delegate.
The tab bar itself is the system UITabBarController, the only thing I added is tintColor. The same tab bar draws the classic look on iOS 18.5 and the Liquid Glass look on iOS 26.2.
Coordinator and Router
Navigation in this structure is split into two layers. The flow level lives in the coordinators in App. Which flow to show, how to set up the tab bar, where logout goes. Transitions inside a module live in the module’s own Router. Here is the detail push in Home:
Projects/HomeModule/Sources/Home/Router/HomeRouter.swift
final class HomeRouter: HomeRoutingProtocol {
weak var viewController: UIViewController?
/// Routes to the homeDetail screen
func routeToDetail() {
let detail = HomeDetailBuilder.build()
viewController?.navigationController?.pushViewController(detail, animated: true)
}
}I chose this split for four reasons. Modules shouldn’t know each other, only App should know about the transitions between modules. A module should handle its own pushes so App doesn’t have to know every screen. Routers can be mocked so view models can be tested without UIKit. On login and logout the whole flow changes from a single place and the old flow leaves memory.
Session management
The login state lives in UserDefaults and the concrete class lives in App. The modules don't see this class. Each one declares a narrow protocol with only the method it needs. PreLoginModule needs to be able to say "I logged in" and the ProfileModule needs to be able to say "I logged out".
Projects/PreLoginModule/Sources/PreLoginSessionStoring.swift
/// Session storage abstraction required by the pre-login flow
public protocol PreLoginSessionStoring: AnyObject {
/// Persists the logged in state after a successful authentication
func markLoggedIn()
}On the App side the three protocols meet in a single typealias and a single class satisfies all three:
Projects/App/Sources/Session/SessionStoring.swift
import PreLoginModule
import ProfileModule
protocol SessionStoring: AnyObject {
var isLoggedIn: Bool { get }
}
typealias AppSessionStoring = SessionStoring & PreLoginSessionStoring & ProfileSessionStoringProjects/App/Sources/Session/UserDefaultsSessionStore.swift
final class UserDefaultsSessionStore: AppSessionStoring {
private let defaults: UserDefaults
var isLoggedIn: Bool {
defaults.bool(forKey: Constant.isLoggedInKey)
}
/// Creates the user defaults backed session store
///
/// - Parameter defaults: User defaults instance used as persistence
init(defaults: UserDefaults = .standard) {
self.defaults = defaults
}
/// Persists the logged in state after a successful authentication
func markLoggedIn() {
defaults.set(true, forKey: Constant.isLoggedInKey)
}
/// Clears the logged in state after the user logs out
func markLoggedOut() {
defaults.set(false, forKey: Constant.isLoggedInKey)
}
}The direction of the dependency flips here. The module declares the protocol, App writes the implementation. PreLoginModule doesn’t know that markLoggedOut exists.
Memory after logout
I checked the claim that “the old flow leaves memory” with the Allocations instrument. I opened the app on my phone and logged in and out twice. The recording ended in the logged out state.
For MainTabBarCoordinator, HomeCoordinator, ExploreCoordinator, BookmarksCoordinator and ProfileCoordinator the numbers read Persistent 0, Transient 2. So they were created once per login and released on every logout. PreLoginCoordinator shows Persistent 1, Transient 1; the first one went away at login, the second one was on screen when the recording ended. The single flowCoordinator reference in setFlow and the weak delegates are doing their job.
Why does AppIcon.icon live in App?
The app icon was drawn with Icon Composer and lives in the App target as Projects/App/Resources/AppIcon.icon. The reason is that the icon compiles into the app bundle's asset catalog and Info.plist, a framework can't provide an app icon.
Since the deployment target is 17.0, the question is what the app icon becomes before iOS 26. The same folder (Resources) also holds an AppIcon.appiconset. I added the app icon there as 1024 pixel light, dark and tinted PNGs. I put them in as a fallback for iOS 17 through 26. Then I ran the actool from Xcode 27.1 with only the .icon file to see what it produces:
$ ls out
AppIcon60x60@2x.png
AppIcon76x76@2x~ipad.png
Assets.car
partial.plist
$ xcrun assetutil --info out/Assets.car | grep -E "AssetType" | sort | uniq -c
6 "AssetType" : "Icon Image",
3 "AssetType" : "IconImageStack",From the .icon file alone, Xcode 27 produces the 60 and 76 point pngs and the flat 1024 pixel Icon Image variants for older versions, while the layered IconImageStack is for iOS 26 and later. So with Xcode 27 the pngs in the appiconset are no longer required. Here is the home screen on both versions:
If there were a deeplink
There is no deeplink in this repo. SceneDelegate doesn't handle URLs and there is no CFBundleURLTypes in the plist. Since the detail builders are internal, App can't build a detail screen from the outside today. Still if we wanted to show how this structure would open with a deeplink:
// Inside SceneDelegate:
func scene(_ scene: UIScene, openURLContexts URLContexts: Set<UIOpenURLContext>) {
guard let url = URLContexts.first?.url else {
return
}
appCoordinator?.handle(url)
}
// Inside AppCoordinator:
func handle(_ url: URL) {
guard sessionStore.isLoggedIn,
let tabBar = flowCoordinator as? MainTabBarCoordinator else {
return
}
tabBar.open(url)
}The flow level decision stays in AppCoordinator. If the user isn't logged in it ignores or stores the URL, if they are, it passes the URL to the relevant tab coordinator. The tab coordinator then goes to the module's public builder. For that, internal builders like HomeDetailBuilder would need a public access point.
Wrapping up
To sum up, the App module ties the seven modules into a single structure. AppCoordinator picks the flow, MainTabBarCoordinator sets up the tabs, events coming from the modules travel up through a chain of weak delegates, the session is shared through protocols and the icon lives here because it compiles into the bundle.
In the next post I’ll go down to the DesignSystem module. We’ll talk about the color tokens, the spacing scale, Bundle.module and the bridge between UIKit and SwiftUI.
I hope you find this useful, see you in the next post.








