Skip to content
8 changes: 4 additions & 4 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ Never import upward. ViewModel must NOT import UI or Coordinator. Model must NOT
- **Views**: SwiftUI views embedded in UIHostingController via UIViewControllers
- **Reactive**: Combine (`@Published`, `CurrentValueSubject`, `.sink`)
- **ViewModel → Coordinator**: Optional closures (`onShowDetail`, `onShowProfile`, etc.)
- **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.
- **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.

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

## Testing
- Swift Testing framework (`import Testing`, `@Test`, `#expect`, `@Suite`)
- Each test creates its own `ServiceLocator()` instance — no `.serialized` needed, tests run in parallel
- Use `makeServiceLocator()` helper to create per-test locator with mocks, pass via `serviceLocator:` param
- Each test creates its own `MockSession` (from `FunModelTestSupport`) — no `.serialized` needed, tests run in parallel
- Use `makeSession()` helper to create per-test session with mocks via `MockSession(serviceLocator:)`, pass via `session:` param
- Consolidate thin init tests into a single test when they test the same concern
- Centralized mocks in `Model/Sources/ModelTestSupport/Mocks/`
- Snapshot tests with swift-snapshot-testing
Expand Down
51 changes: 28 additions & 23 deletions Coordinator/Sources/Coordinator/AppCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,16 +13,16 @@ import FunUI
import FunViewModel

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

// MARK: - Services

public private(set) var session: Session
@Service(.logger) private var logger: LoggerService

// MARK: - Session Management

private let sessionFactory: SessionFactory
private var currentSession: Session?

// MARK: - App Flow State

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

// MARK: - Init

public init(navigationController: UINavigationController, sessionFactory: SessionFactory, serviceLocator: ServiceLocator) {
public init(navigationController: UINavigationController, sessionFactory: SessionFactory) {
self.sessionFactory = sessionFactory
super.init(navigationController: navigationController, serviceLocator: serviceLocator)
self.session = sessionFactory.makeSession(for: .login)
super.init(navigationController: navigationController)
}

// MARK: - Start

override public func start() {
activateSession(for: currentFlow)
activateCurrentSession()
switch currentFlow {
case .login:
showLoginFlow()
showLoginFlow(session: session)
case .main:
showMainFlow()
showMainFlow(session: session)
}
}

// MARK: - Session Lifecycle

private func activateSession(for flow: AppFlow) {
currentSession?.teardown()
let session = sessionFactory.makeSession(for: flow, serviceLocator: serviceLocator)
private func activateCurrentSession() {
session.activate()
currentSession = session
onSessionActivated?()
}

private func activateSession(for flow: AppFlow) -> Session {
session.teardown()
session = sessionFactory.makeSession(for: flow)
activateCurrentSession()
return session
}

// MARK: - Flow Management

private func showLoginFlow() {
private func showLoginFlow(session: Session) {
// Clear any existing main flow coordinators
clearMainFlowCoordinators()

let loginCoordinator = LoginCoordinator(
navigationController: navigationController,
serviceLocator: serviceLocator
session: session
)
loginCoordinator.onLoginSuccess = { [weak self] in
self?.transitionToMainFlow()
Expand All @@ -90,7 +95,7 @@ public final class AppCoordinator: BaseCoordinator {
loginCoordinator.start()
}

private func showMainFlow() {
private func showMainFlow(session: Session) {
// Clear login coordinator
loginCoordinator = nil

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

// Create view model for tab bar
let tabBarViewModel = HomeTabBarViewModel(serviceLocator: serviceLocator)
let tabBarViewModel = HomeTabBarViewModel(session: session)
self.tabBarViewModel = tabBarViewModel

// Create and store coordinators for each tab
let homeCoordinator = HomeCoordinator(
navigationController: homeNavController,
serviceLocator: serviceLocator
session: session
)
let itemsCoordinator = ItemsCoordinator(
navigationController: itemsNavController,
serviceLocator: serviceLocator
session: session
)
let settingsCoordinator = SettingsCoordinator(
navigationController: settingsNavController,
serviceLocator: serviceLocator
session: session
)

// Set up logout callback through home coordinator (Profile modal)
Expand All @@ -167,7 +172,7 @@ public final class AppCoordinator: BaseCoordinator {
itemsNavController,
settingsNavController
],
serviceLocator: serviceLocator
session: session
)

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

private func transitionToMainFlow() {
currentFlow = .main
activateSession(for: .main)
showMainFlow()
let session = activateSession(for: .main)
showMainFlow(session: session)

if let deepLink = pendingDeepLink {
pendingDeepLink = nil
Expand All @@ -190,8 +195,8 @@ public final class AppCoordinator: BaseCoordinator {
private func transitionToLoginFlow() {
currentFlow = .login
pendingDeepLink = nil
activateSession(for: .login)
showLoginFlow()
let session = activateSession(for: .login)
showLoginFlow(session: session)
}

// MARK: - Cleanup
Expand Down
8 changes: 2 additions & 6 deletions Coordinator/Sources/Coordinator/BaseCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,6 @@

import UIKit

import FunCore

// MARK: - Coordinator Protocol

@MainActor
Expand All @@ -20,10 +18,9 @@ public protocol Coordinator: AnyObject {
// MARK: - Base Coordinator

@MainActor
open class BaseCoordinator: Coordinator, ServiceLocatorProvider {
open class BaseCoordinator: Coordinator {

public let navigationController: UINavigationController
public let serviceLocator: ServiceLocator

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

public init(navigationController: UINavigationController, serviceLocator: ServiceLocator) {
public init(navigationController: UINavigationController) {
self.navigationController = navigationController
self.serviceLocator = serviceLocator
}

open func start() {
Expand Down
14 changes: 11 additions & 3 deletions Coordinator/Sources/Coordinator/HomeCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

import UIKit

import FunCore
import FunModel
import FunUI
import FunViewModel
Expand All @@ -15,13 +16,20 @@ public final class HomeCoordinator: BaseCoordinator {

// MARK: - Properties

private let session: Session

/// Callback to notify parent coordinator of logout
public var onLogout: (() -> Void)?

private var isShowingDetail = false

public init(navigationController: UINavigationController, session: Session) {
self.session = session
super.init(navigationController: navigationController)
}

override public func start() {
let viewModel = HomeViewModel(serviceLocator: serviceLocator)
let viewModel = HomeViewModel(session: session)
viewModel.onShowDetail = { [weak self] item in self?.showDetail(for: item) }
viewModel.onShowProfile = { [weak self] in self?.showProfile() }

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

let viewModel = DetailViewModel(item: item, serviceLocator: serviceLocator)
let viewModel = DetailViewModel(item: item, session: session)
viewModel.onPop = { [weak self] in self?.isShowingDetail = false }
viewModel.onShare = { [weak self] text in self?.share(text: text) }

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

let viewModel = ProfileViewModel(serviceLocator: serviceLocator)
let viewModel = ProfileViewModel(session: session)
viewModel.onDismiss = { [weak self] in self?.safeDismiss() }
viewModel.onLogout = { [weak self] in self?.safeDismiss { self?.onLogout?() } }
viewModel.onGoToItems = { [weak self] in
Expand Down
11 changes: 9 additions & 2 deletions Coordinator/Sources/Coordinator/ItemsCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,23 @@

import UIKit

import FunCore
import FunModel
import FunUI
import FunViewModel

public final class ItemsCoordinator: BaseCoordinator {

private let session: Session
private var isShowingDetail = false

public init(navigationController: UINavigationController, session: Session) {
self.session = session
super.init(navigationController: navigationController)
}

override public func start() {
let viewModel = ItemsViewModel(serviceLocator: serviceLocator)
let viewModel = ItemsViewModel(session: session)
viewModel.onShowDetail = { [weak self] item in self?.showDetail(for: item) }

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

let viewModel = DetailViewModel(item: item, serviceLocator: serviceLocator)
let viewModel = DetailViewModel(item: item, session: session)
viewModel.onPop = { [weak self] in self?.isShowingDetail = false }
viewModel.onShare = { [weak self] text in self?.share(text: text) }

Expand Down
10 changes: 9 additions & 1 deletion Coordinator/Sources/Coordinator/LoginCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,18 +7,26 @@

import UIKit

import FunCore
import FunUI
import FunViewModel

public final class LoginCoordinator: BaseCoordinator {

// MARK: - Properties

private let session: Session

/// Callback to notify parent coordinator of successful login
public var onLoginSuccess: (() -> Void)?

public init(navigationController: UINavigationController, session: Session) {
self.session = session
super.init(navigationController: navigationController)
}

override public func start() {
let viewModel = LoginViewModel(serviceLocator: serviceLocator)
let viewModel = LoginViewModel(session: session)
viewModel.onLogin = { [weak self] in self?.onLoginSuccess?() }

let viewController = LoginViewController(viewModel: viewModel)
Expand Down
10 changes: 9 additions & 1 deletion Coordinator/Sources/Coordinator/SettingsCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,21 @@

import UIKit

import FunCore
import FunUI
import FunViewModel

public final class SettingsCoordinator: BaseCoordinator {

private let session: Session

public init(navigationController: UINavigationController, session: Session) {
self.session = session
super.init(navigationController: navigationController)
}

override public func start() {
let viewModel = SettingsViewModel(serviceLocator: serviceLocator)
let viewModel = SettingsViewModel(session: session)
let viewController = SettingsViewController(viewModel: viewModel)
navigationController.setViewControllers([viewController], animated: false)
}
Expand Down
25 changes: 23 additions & 2 deletions Core/Sources/Core/ServiceLocator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,10 @@
//
// Central registry for dependency injection.
//
// Instance-based DI: the app creates one ServiceLocator() at the top (SceneDelegate)
// and threads it through coordinators, sessions, and ViewModels. No global singleton.
// Session-scoped DI: each Session creates its own ServiceLocator and registers
// services into it. On session transition, the old ServiceLocator is released
// with the session — no stale services. ViewModels receive the session and
// conform to SessionProvider, which auto-provides serviceLocator for @Service.
//

import Foundation
Expand Down Expand Up @@ -60,10 +62,29 @@ public class ServiceLocator {
// MARK: - ServiceLocatorProvider

/// Any type that holds a ServiceLocator instance for instance-based DI resolution.
///
/// `@MainActor` is required because `ServiceLocator` itself is `@MainActor` —
/// any property that returns a `ServiceLocator` must also be isolated to the main actor.
@MainActor
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why need main actor now but not before?

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Copy Markdown

@claude claude bot Mar 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude finished @g-enius's task in 1m 39s —— View job


Why @MainActor was added to ServiceLocatorProvider

Before this PR (main branch): ServiceLocatorProvider had no @MainActor. All concrete conformers (ViewModels) happened to be @MainActor classes, but the protocol requirement itself was nonisolated — Swift accepted this because the concrete types' isolation satisfied the call sites.

What changed: This PR introduced SessionProvider, a @MainActor protocol that inherits from ServiceLocatorProvider and provides a default implementation:

@MainActor
public protocol SessionProvider: ServiceLocatorProvider {
    var session: Session { get }
}

extension SessionProvider {
    public var serviceLocator: ServiceLocator { session.serviceLocator }
}

The Swift 6 strict concurrency rule: A @MainActor-isolated implementation cannot satisfy a nonisolated protocol requirement. Since SessionProvider is @MainActor, its serviceLocator implementation is main-actor-isolated. If ServiceLocatorProvider.serviceLocator remained nonisolated, Swift 6 would error — the protocol witness table requires isolation to match.

Adding @MainActor to ServiceLocatorProvider aligns the requirement's isolation with the implementation's isolation, resolving the violation cleanly.

In short: It wasn't needed before because no @MainActor protocol inherited from it. Once SessionProvider: ServiceLocatorProvider was introduced, Swift 6 required the requirement and implementation to agree on actor isolation.


· Branch

public protocol ServiceLocatorProvider {
var serviceLocator: ServiceLocator { get }
}

// MARK: - SessionProvider

/// Types that hold a Session reference. Provides `serviceLocator` automatically
/// from `session.serviceLocator`, so conformers only need to store `let session: Session`.
@MainActor
public protocol SessionProvider: ServiceLocatorProvider {
var session: Session { get }
}

// MARK: - ServiceLocatorProvider

extension SessionProvider {
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should write protocol conformance here

public var serviceLocator: ServiceLocator { session.serviceLocator }
}

// MARK: - @Service Property Wrapper

/// Property wrapper that resolves services from the enclosing instance's ServiceLocator.
Expand Down
10 changes: 5 additions & 5 deletions Core/Sources/Core/Session.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,13 @@
import Foundation

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

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