Skip to content

Commit fbc016b

Browse files
authored
Merge pull request #7 from g-enius/refactor/session-scoped-service-locator
Refactor ServiceLocator to session-scoped ownership
2 parents 04272ff + a8bd4ed commit fbc016b

42 files changed

Lines changed: 413 additions & 340 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

CLAUDE.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ Never import upward. ViewModel must NOT import UI or Coordinator. Model must NOT
5959
- **Views**: SwiftUI views embedded in UIHostingController via UIViewControllers
6060
- **Reactive**: Combine (`@Published`, `CurrentValueSubject`, `.sink`)
6161
- **ViewModel → Coordinator**: Optional closures (`onShowDetail`, `onShowProfile`, etc.)
62-
- **DI**: Instance-based ServiceLocator — no `.shared` singleton. `@Service` property wrapper resolves via `static subscript(_enclosingInstance:)` from the enclosing type's `serviceLocator` (requires `ServiceLocatorProvider` conformance). One `ServiceLocator()` created in SceneDelegate, threaded through coordinators → sessions → ViewModels.
62+
- **DI**: Session-scoped ServiceLocator — no `.shared` singleton. Each `Session` creates and owns its own `ServiceLocator`. On session transition, the old ServiceLocator is released with the session (no stale services). `@Service` property wrapper resolves via `static subscript(_enclosingInstance:)` from the enclosing type's `serviceLocator` (requires `ServiceLocatorProvider` conformance). Coordinators and ViewModels receive the current session's ServiceLocator via constructor injection.
6363

6464
## Rule Index
6565
Consult these files for detailed guidance (not auto-loaded — read on demand):
@@ -73,13 +73,13 @@ Consult these files for detailed guidance (not auto-loaded — read on demand):
7373
- ViewModels use closures for navigation (no coordinator protocols)
7474
- Navigation logic ONLY in Coordinators, never in Views
7575
- Protocol placement: Core = reusable abstractions, Model = domain-specific
76-
- Instance-based ServiceLocator with `@Service` property wrapper (`ServiceLocatorProvider` conformance)
76+
- Session-scoped ServiceLocator with `@Service` property wrapper — ViewModels conform to `SessionProvider`, store `let session: Session`
7777
- Combine over NotificationCenter for reactive state
7878

7979
## Testing
8080
- Swift Testing framework (`import Testing`, `@Test`, `#expect`, `@Suite`)
81-
- Each test creates its own `ServiceLocator()` instance — no `.serialized` needed, tests run in parallel
82-
- Use `makeServiceLocator()` helper to create per-test locator with mocks, pass via `serviceLocator:` param
81+
- Each test creates its own `MockSession` (from `FunModelTestSupport`) — no `.serialized` needed, tests run in parallel
82+
- Use `makeSession()` helper to create per-test session with mocks via `MockSession(serviceLocator:)`, pass via `session:` param
8383
- Consolidate thin init tests into a single test when they test the same concern
8484
- Centralized mocks in `Model/Sources/ModelTestSupport/Mocks/`
8585
- Snapshot tests with swift-snapshot-testing

Coordinator/Sources/Coordinator/AppCoordinator.swift

Lines changed: 28 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -13,16 +13,16 @@ import FunUI
1313
import FunViewModel
1414

1515
/// Main app coordinator that manages the root navigation and app flow
16-
public final class AppCoordinator: BaseCoordinator {
16+
public final class AppCoordinator: BaseCoordinator, SessionProvider {
1717

1818
// MARK: - Services
1919

20+
public private(set) var session: Session
2021
@Service(.logger) private var logger: LoggerService
2122

2223
// MARK: - Session Management
2324

2425
private let sessionFactory: SessionFactory
25-
private var currentSession: Session?
2626

2727
// MARK: - App Flow State
2828

@@ -46,42 +46,47 @@ public final class AppCoordinator: BaseCoordinator {
4646

4747
// MARK: - Init
4848

49-
public init(navigationController: UINavigationController, sessionFactory: SessionFactory, serviceLocator: ServiceLocator) {
49+
public init(navigationController: UINavigationController, sessionFactory: SessionFactory) {
5050
self.sessionFactory = sessionFactory
51-
super.init(navigationController: navigationController, serviceLocator: serviceLocator)
51+
self.session = sessionFactory.makeSession(for: .login)
52+
super.init(navigationController: navigationController)
5253
}
5354

5455
// MARK: - Start
5556

5657
override public func start() {
57-
activateSession(for: currentFlow)
58+
activateCurrentSession()
5859
switch currentFlow {
5960
case .login:
60-
showLoginFlow()
61+
showLoginFlow(session: session)
6162
case .main:
62-
showMainFlow()
63+
showMainFlow(session: session)
6364
}
6465
}
6566

6667
// MARK: - Session Lifecycle
6768

68-
private func activateSession(for flow: AppFlow) {
69-
currentSession?.teardown()
70-
let session = sessionFactory.makeSession(for: flow, serviceLocator: serviceLocator)
69+
private func activateCurrentSession() {
7170
session.activate()
72-
currentSession = session
7371
onSessionActivated?()
7472
}
7573

74+
private func activateSession(for flow: AppFlow) -> Session {
75+
session.teardown()
76+
session = sessionFactory.makeSession(for: flow)
77+
activateCurrentSession()
78+
return session
79+
}
80+
7681
// MARK: - Flow Management
7782

78-
private func showLoginFlow() {
83+
private func showLoginFlow(session: Session) {
7984
// Clear any existing main flow coordinators
8085
clearMainFlowCoordinators()
8186

8287
let loginCoordinator = LoginCoordinator(
8388
navigationController: navigationController,
84-
serviceLocator: serviceLocator
89+
session: session
8590
)
8691
loginCoordinator.onLoginSuccess = { [weak self] in
8792
self?.transitionToMainFlow()
@@ -90,7 +95,7 @@ public final class AppCoordinator: BaseCoordinator {
9095
loginCoordinator.start()
9196
}
9297

93-
private func showMainFlow() {
98+
private func showMainFlow(session: Session) {
9499
// Clear login coordinator
95100
loginCoordinator = nil
96101

@@ -127,21 +132,21 @@ public final class AppCoordinator: BaseCoordinator {
127132
settingsNavController.tabBarItem.accessibilityIdentifier = AccessibilityID.Tabs.settings
128133

129134
// Create view model for tab bar
130-
let tabBarViewModel = HomeTabBarViewModel(serviceLocator: serviceLocator)
135+
let tabBarViewModel = HomeTabBarViewModel(session: session)
131136
self.tabBarViewModel = tabBarViewModel
132137

133138
// Create and store coordinators for each tab
134139
let homeCoordinator = HomeCoordinator(
135140
navigationController: homeNavController,
136-
serviceLocator: serviceLocator
141+
session: session
137142
)
138143
let itemsCoordinator = ItemsCoordinator(
139144
navigationController: itemsNavController,
140-
serviceLocator: serviceLocator
145+
session: session
141146
)
142147
let settingsCoordinator = SettingsCoordinator(
143148
navigationController: settingsNavController,
144-
serviceLocator: serviceLocator
149+
session: session
145150
)
146151

147152
// Set up logout callback through home coordinator (Profile modal)
@@ -167,7 +172,7 @@ public final class AppCoordinator: BaseCoordinator {
167172
itemsNavController,
168173
settingsNavController
169174
],
170-
serviceLocator: serviceLocator
175+
session: session
171176
)
172177

173178
// Set as root (tab bar doesn't push, it's the container)
@@ -178,8 +183,8 @@ public final class AppCoordinator: BaseCoordinator {
178183

179184
private func transitionToMainFlow() {
180185
currentFlow = .main
181-
activateSession(for: .main)
182-
showMainFlow()
186+
let session = activateSession(for: .main)
187+
showMainFlow(session: session)
183188

184189
if let deepLink = pendingDeepLink {
185190
pendingDeepLink = nil
@@ -190,8 +195,8 @@ public final class AppCoordinator: BaseCoordinator {
190195
private func transitionToLoginFlow() {
191196
currentFlow = .login
192197
pendingDeepLink = nil
193-
activateSession(for: .login)
194-
showLoginFlow()
198+
let session = activateSession(for: .login)
199+
showLoginFlow(session: session)
195200
}
196201

197202
// MARK: - Cleanup

Coordinator/Sources/Coordinator/BaseCoordinator.swift

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,6 @@
77

88
import UIKit
99

10-
import FunCore
11-
1210
// MARK: - Coordinator Protocol
1311

1412
@MainActor
@@ -20,10 +18,9 @@ public protocol Coordinator: AnyObject {
2018
// MARK: - Base Coordinator
2119

2220
@MainActor
23-
open class BaseCoordinator: Coordinator, ServiceLocatorProvider {
21+
open class BaseCoordinator: Coordinator {
2422

2523
public let navigationController: UINavigationController
26-
public let serviceLocator: ServiceLocator
2724

2825
private var isTransitioning: Bool {
2926
navigationController.transitionCoordinator != nil
@@ -33,9 +30,8 @@ open class BaseCoordinator: Coordinator, ServiceLocatorProvider {
3330
/// Handles deep links arriving mid-transition without full queue complexity.
3431
private var pendingAction: (@MainActor () -> Void)?
3532

36-
public init(navigationController: UINavigationController, serviceLocator: ServiceLocator) {
33+
public init(navigationController: UINavigationController) {
3734
self.navigationController = navigationController
38-
self.serviceLocator = serviceLocator
3935
}
4036

4137
open func start() {

Coordinator/Sources/Coordinator/HomeCoordinator.swift

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
import UIKit
99

10+
import FunCore
1011
import FunModel
1112
import FunUI
1213
import FunViewModel
@@ -15,13 +16,20 @@ public final class HomeCoordinator: BaseCoordinator {
1516

1617
// MARK: - Properties
1718

19+
private let session: Session
20+
1821
/// Callback to notify parent coordinator of logout
1922
public var onLogout: (() -> Void)?
2023

2124
private var isShowingDetail = false
2225

26+
public init(navigationController: UINavigationController, session: Session) {
27+
self.session = session
28+
super.init(navigationController: navigationController)
29+
}
30+
2331
override public func start() {
24-
let viewModel = HomeViewModel(serviceLocator: serviceLocator)
32+
let viewModel = HomeViewModel(session: session)
2533
viewModel.onShowDetail = { [weak self] item in self?.showDetail(for: item) }
2634
viewModel.onShowProfile = { [weak self] in self?.showProfile() }
2735

@@ -35,7 +43,7 @@ public final class HomeCoordinator: BaseCoordinator {
3543
guard !isShowingDetail else { return }
3644
isShowingDetail = true
3745

38-
let viewModel = DetailViewModel(item: item, serviceLocator: serviceLocator)
46+
let viewModel = DetailViewModel(item: item, session: session)
3947
viewModel.onPop = { [weak self] in self?.isShowingDetail = false }
4048
viewModel.onShare = { [weak self] text in self?.share(text: text) }
4149

@@ -46,7 +54,7 @@ public final class HomeCoordinator: BaseCoordinator {
4654
public func showProfile() {
4755
let profileNavController = UINavigationController()
4856

49-
let viewModel = ProfileViewModel(serviceLocator: serviceLocator)
57+
let viewModel = ProfileViewModel(session: session)
5058
viewModel.onDismiss = { [weak self] in self?.safeDismiss() }
5159
viewModel.onLogout = { [weak self] in self?.safeDismiss { self?.onLogout?() } }
5260
viewModel.onGoToItems = { [weak self] in

Coordinator/Sources/Coordinator/ItemsCoordinator.swift

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,16 +7,23 @@
77

88
import UIKit
99

10+
import FunCore
1011
import FunModel
1112
import FunUI
1213
import FunViewModel
1314

1415
public final class ItemsCoordinator: BaseCoordinator {
1516

17+
private let session: Session
1618
private var isShowingDetail = false
1719

20+
public init(navigationController: UINavigationController, session: Session) {
21+
self.session = session
22+
super.init(navigationController: navigationController)
23+
}
24+
1825
override public func start() {
19-
let viewModel = ItemsViewModel(serviceLocator: serviceLocator)
26+
let viewModel = ItemsViewModel(session: session)
2027
viewModel.onShowDetail = { [weak self] item in self?.showDetail(for: item) }
2128

2229
let viewController = ItemsViewController(viewModel: viewModel)
@@ -29,7 +36,7 @@ public final class ItemsCoordinator: BaseCoordinator {
2936
guard !isShowingDetail else { return }
3037
isShowingDetail = true
3138

32-
let viewModel = DetailViewModel(item: item, serviceLocator: serviceLocator)
39+
let viewModel = DetailViewModel(item: item, session: session)
3340
viewModel.onPop = { [weak self] in self?.isShowingDetail = false }
3441
viewModel.onShare = { [weak self] text in self?.share(text: text) }
3542

Coordinator/Sources/Coordinator/LoginCoordinator.swift

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,18 +7,26 @@
77

88
import UIKit
99

10+
import FunCore
1011
import FunUI
1112
import FunViewModel
1213

1314
public final class LoginCoordinator: BaseCoordinator {
1415

1516
// MARK: - Properties
1617

18+
private let session: Session
19+
1720
/// Callback to notify parent coordinator of successful login
1821
public var onLoginSuccess: (() -> Void)?
1922

23+
public init(navigationController: UINavigationController, session: Session) {
24+
self.session = session
25+
super.init(navigationController: navigationController)
26+
}
27+
2028
override public func start() {
21-
let viewModel = LoginViewModel(serviceLocator: serviceLocator)
29+
let viewModel = LoginViewModel(session: session)
2230
viewModel.onLogin = { [weak self] in self?.onLoginSuccess?() }
2331

2432
let viewController = LoginViewController(viewModel: viewModel)

Coordinator/Sources/Coordinator/SettingsCoordinator.swift

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,21 @@
77

88
import UIKit
99

10+
import FunCore
1011
import FunUI
1112
import FunViewModel
1213

1314
public final class SettingsCoordinator: BaseCoordinator {
1415

16+
private let session: Session
17+
18+
public init(navigationController: UINavigationController, session: Session) {
19+
self.session = session
20+
super.init(navigationController: navigationController)
21+
}
22+
1523
override public func start() {
16-
let viewModel = SettingsViewModel(serviceLocator: serviceLocator)
24+
let viewModel = SettingsViewModel(session: session)
1725
let viewController = SettingsViewController(viewModel: viewModel)
1826
navigationController.setViewControllers([viewController], animated: false)
1927
}

Core/Sources/Core/ServiceLocator.swift

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,10 @@
44
//
55
// Central registry for dependency injection.
66
//
7-
// Instance-based DI: the app creates one ServiceLocator() at the top (SceneDelegate)
8-
// and threads it through coordinators, sessions, and ViewModels. No global singleton.
7+
// Session-scoped DI: each Session creates its own ServiceLocator and registers
8+
// services into it. On session transition, the old ServiceLocator is released
9+
// with the session — no stale services. ViewModels receive the session and
10+
// conform to SessionProvider, which auto-provides serviceLocator for @Service.
911
//
1012

1113
import Foundation
@@ -60,10 +62,29 @@ public class ServiceLocator {
6062
// MARK: - ServiceLocatorProvider
6163

6264
/// Any type that holds a ServiceLocator instance for instance-based DI resolution.
65+
///
66+
/// `@MainActor` is required because `ServiceLocator` itself is `@MainActor` —
67+
/// any property that returns a `ServiceLocator` must also be isolated to the main actor.
68+
@MainActor
6369
public protocol ServiceLocatorProvider {
6470
var serviceLocator: ServiceLocator { get }
6571
}
6672

73+
// MARK: - SessionProvider
74+
75+
/// Types that hold a Session reference. Provides `serviceLocator` automatically
76+
/// from `session.serviceLocator`, so conformers only need to store `let session: Session`.
77+
@MainActor
78+
public protocol SessionProvider: ServiceLocatorProvider {
79+
var session: Session { get }
80+
}
81+
82+
// MARK: - ServiceLocatorProvider
83+
84+
extension SessionProvider {
85+
public var serviceLocator: ServiceLocator { session.serviceLocator }
86+
}
87+
6788
// MARK: - @Service Property Wrapper
6889

6990
/// Property wrapper that resolves services from the enclosing instance's ServiceLocator.

Core/Sources/Core/Session.swift

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,13 @@
88
import Foundation
99

1010
/// A session represents a scoped set of services for a given app flow.
11-
/// When transitioning between flows, the old session is torn down and a new one activated.
12-
/// Each session operates on the ServiceLocator instance it was created with.
11+
/// Each session owns its own ServiceLocator — when the session is released,
12+
/// its services are released with it. No stale services across transitions.
1313
@MainActor
14-
public protocol Session: AnyObject {
15-
/// Register services for this session into the ServiceLocator
14+
public protocol Session: AnyObject, ServiceLocatorProvider {
15+
/// Register services for this session into its ServiceLocator
1616
func activate()
1717

18-
/// Tear down and unregister services for this session
18+
/// Tear down session-specific state (e.g. clear user data)
1919
func teardown()
2020
}

0 commit comments

Comments
 (0)