Communication Patterns
How components communicate across the Youll platform using Combine publishers, delegates, closures, singletons, and NotificationCenter.
Why Multiple Communication Patterns
A modular iOS platform cannot rely on a single communication mechanism. Different relationships between components call for different patterns: a ViewModel publishing UI state to its view is fundamentally different from a module notifying its host app that an event occurred, which is different again from a coordinator completing a one-shot async operation.
The Youll platform uses five communication mechanisms, each serving a specific scope:
| Mechanism | Primary Use | Scope |
|---|---|---|
| Combine publishers | Reactive state streams | Intra-module and cross-module |
| Delegate protocols | Module boundary contracts | Inter-module |
| Closures and callbacks | One-shot actions and navigation | Intra-module |
| Singletons and shared state | Centralized app-wide services | Cross-module |
| NotificationCenter | Broadcast events (rare) | Cross-module |
These mechanisms form a layered strategy. The diagram below shows which pattern is used at each architectural boundary:
graph TD
subgraph "Customer App"
SD[SceneDelegate]
end
subgraph "Client Module"
AC[AppCoordinator]
MC[MainCoordinator]
TC[Tab Coordinators]
SC[Static Coordinators]
CFG[Config.shared]
end
subgraph "Core Module"
VM[ViewModels]
SVC[Services]
MI[Module Interfaces]
SET[Settings]
end
subgraph "Feature Modules"
TM[Timer]
BIO[Biometrics]
COM[Community]
TUY[Tuya]
end
SD -->|"module injection"| AC
AC -->|"delegates"| MC
MC -->|"delegates"| TC
TC -->|"closures"| VM
VM -->|"Combine @Published"| TC
SC -->|"Combine publishers"| TC
CFG -->|"Combine publishers"| AC
SVC -->|"Combine publishers"| VM
MI -->|"delegates"| TM
MI -->|"delegates"| BIO
MI -->|"delegates"| COM
MI -->|"delegates"| TUYCombine Publishers
Combine is the dominant communication pattern in the platform. It provides reactive, type-safe data streams that automatically propagate state changes from services through ViewModels to views.
Publisher Types
The platform uses three Combine publisher types, each with a distinct purpose:
| Type | Holds Value? | Use Case | Example |
|---|---|---|---|
@Published | Yes (on property) | ViewModel state bound to UI | @Published var playerState: PlayerState |
CurrentValueSubject | Yes (explicit) | Shared state with initial value | CurrentValueSubject<Bool, Never>(false) |
PassthroughSubject | No | Fire-and-forget events | PassthroughSubject<Void, Never>() |
When to choose each:
- Use
@Publishedin ViewModels where SwiftUI or UIKit views observe the property directly. - Use
CurrentValueSubjectwhen the current value matters to late subscribers (e.g., network reachability status, user profile state). New subscribers immediately receive the latest value. - Use
PassthroughSubjectfor events where only future occurrences matter (e.g., "player closed", "registration completed"). Late subscribers do not receive past events.
All publishers in the platform use Never as the error type. Errors are handled within services before publishing, so subscribers never need error handling logic.
Key Publishers in the Platform
These publishers form the backbone of reactive state in the platform:
UserService.user is the central user state publisher. Every coordinator, ViewModel, and service that needs the current user subscribes to it:
// Core/Data/API Service/UserService.swift
public var user = CurrentValueSubject<YNProfile?, Never>(nil)
private var observers = [AnyCancellable]()
public init(network: BricksNetwork, stats: [String], ...) {
// Load cached user
user.value = DataApiCache.get(network: network)
// Listen for user changes from the network layer
UserAPI.currentUser
.receive(on: DispatchQueue.main)
.dropFirst()
.sink { [weak self] user in
self?.user.send(user)
}
.store(in: &observers)
}Config publishers expose app-wide state changes. Config acts as the hub that services and coordinators subscribe to:
// Client/App/Config.swift
public let registrationCompletionPublisher = PassthroughSubject<Void, Never>()
public let subscriptionCompletionPublisher = CurrentValueSubject<Bool, Never>(false)
public var didGetAppConfiguration = CurrentValueSubject<Bool, Never>(false)
public let requiredNetworkCheckPublisher = PassthroughSubject<Void, Never>()NetworkReachability.reachablePublisher tracks connectivity status. Services check this before attempting network operations:
// YoullNetwork/Sources/Public/NetworkReachability.swift
public class NetworkReachability {
public var reachablePublisher = CurrentValueSubject<Bool, Never>(false)
public var reachable: Bool { reachablePublisher.value }
nonisolated(unsafe) public static let shared = NetworkReachability()
}Coordinator publishers (ContentDetailsCoordinator, ProfileCoordinator, QuizCoordinator) are covered in the Coordinator Pattern page. They follow the same Combine patterns but are specific to coordinator-to-coordinator communication.
For the Combine subscribers that run during app launch (user state observer, notification authorization observer, tracking authorization observer), see Bootstrap Sequence, Step 9.
Subscription Lifecycle
Every Combine subscription must be stored or it will be immediately cancelled. The platform follows a consistent pattern:
// Typical subscription pattern in a coordinator or ViewModel
private var observers = Set<AnyCancellable>()
func setupObservers() {
config.userService.user
.receive(on: DispatchQueue.main)
.sink { [weak self] user in
self?.handleUserChange(user)
}
.store(in: &observers)
}Three rules to follow:
- Store subscriptions in a
Set<AnyCancellable>property (namedobserversby convention). When the owning object is deallocated, all subscriptions cancel automatically. - Use
[weak self]in every.sinkclosure to prevent retain cycles. The publisher holds a strong reference to the subscription, and the subscription holds a reference to whatever is captured in the closure. - Use
.receive(on: DispatchQueue.main)when the subscriber updates UI. Publishers from services may emit on background threads.
Delegate Protocols
Delegates provide typed, one-to-one communication contracts. The platform uses them primarily at module boundaries where a clear caller-callee relationship exists.
Module Interface Delegates
The four optional modules (Timer, Biometrics, Community, Tuya) communicate with the rest of the platform through interface protocols defined in Core. Each interface has a corresponding delegate protocol for callbacks:
| Module Interface | Delegate Protocol | Key Callbacks |
|---|---|---|
TimerModuleInterface | TimerModuleDelegate | timerBadgeDidReceiveBadge(_:) |
CommunityModuleInterface | CommunityModuleInterfaceDelegate | communityDidSelectSession(...), communityDidSelectJourney(...), communityDidSelectURL(_:) |
BiometricsModuleInterface | (inline in protocol) | createCoordinator(...) |
TuyaModuleInterface | (inline in protocol) | createTuyaCoordinator(...) |
The pattern follows a consistent structure. Core defines the protocol, the feature module implements it, and the customer app injects the module at runtime:
// Core/App/Module Interface/TimerModuleInterface.swift
@MainActor
public protocol TimerModuleInterface: AnyObject {
var delegate: TimerModuleDelegate? { get set }
var isOnScreen: Bool { get }
func showTimerSettingsController(
from navigationController: UINavigationController,
tuyaController: TuyaControllerProtocol?)
func showTimerViewController(
from navigationController: UINavigationController,
tuyaController: TuyaControllerProtocol?)
}
public protocol TimerModuleDelegate: AnyObject {
func timerBadgeDidReceiveBadge(_ badge: Badge)
}The delegate is always declared as weak var delegate in the implementing module to prevent retain cycles. The coordinator that owns the module sets itself as the delegate.
Authentication Delegates
The Onboarding module defines AuthenticationDelegate for auth flow callbacks. This is separate from coordinator-level delegates:
// Onboarding/Public Interface/AuthenticationDelegate.swift
@MainActor
public protocol AuthenticationDelegate: AnyObject {
func interactWithURL(_ url: URL)
func didAuthenticateWithSuccess(_ user: YNProfile, platform: LoginType?)
func didFailAuthenticateWithError(_ error: Error)
func didFailAuthenticateWithErrorMessage(_ message: String)
}AppCoordinator conforms to AuthenticationDelegate and handles the auth result by transitioning to the main app or showing an error.
Hybrid Delegates
Some delegates combine traditional delegate methods with Combine publishers. AppleHealthDelegate is an example:
// Core/App/AppleHealthDelegate.swift
public protocol AppleHealthDelegate: AnyObject {
var authorisationPublisher: CurrentValueSubject<Bool, Never> { get }
func requestAuthorization()
func configureForBiometricsTracking()
}The publisher exposes reactive state (whether HealthKit is authorized), while the methods provide imperative actions. This hybrid approach is used when a delegate needs to expose both "do something" actions and "observe this state" capabilities.
Coordinator navigation delegates (MainCoordinatorDelegate, NavigationDelegate, PlayerDelegate, SubscriptionCoordinatorDelegate) are covered in the Coordinator Pattern page.
Closures and Callbacks
Closures are the platform's pattern for imperative, one-shot communication. They are most commonly used for ViewModel navigation actions and async completion handlers.
ViewModel Action Closures
Coordinators inject navigation closures into ViewModels when creating them. This keeps ViewModels unaware of the navigation layer:
// Timer/Content/TimerModule.swift
let viewmodel = TimerSettingsViewModel(
screenConfig: screenConfig,
analyticsManager: analytics)
weak let weakNavController = navigationController
viewmodel.onClose = {
weakNavController?.dismiss(animated: true)
}
viewmodel.onSave = { [weak self] in
if let weakNavController {
weakNavController.dismiss(animated: true, completion: {
self?.showTimerViewController(
from: weakNavController,
tuyaController: tuyaController)
})
}
}The Community module follows the same pattern, with closures bridging ViewModel actions to coordinator or delegate calls:
// Community/Content/CommunityCoordinator.swift
viewModel.onShowModerationQueue = { [weak self] in
self?.showModerationQueue()
}
viewModel.onNavigateToJourney = { [weak self] journey in
guard let self else { return }
delegate?.communityDidSelectJourney(
journey,
navigationController: navigationController)
}This pattern keeps the communication direction clear: the coordinator decides what happens, the ViewModel just triggers the action.
View Lifecycle Closures
View controllers sometimes expose lifecycle closures for the coordinator to react to view events:
// Timer/Content/TimerModule.swift
let viewController = TimerSettingsViewController(viewModel: viewmodel)
viewController.viewDidDissapearHandler = { [weak self] in
self?.requiredNetworkCheckPublisher.send()
}Completion Handlers
API calls and async operations use trailing closure completion handlers. These are being gradually replaced by async/await as modules adopt Swift concurrency:
// Client/Coordinators/Static Coordinators/SubscriptionCoordinator.swift
paymentService.getSuperPaywallConfig(
superPaywallID: id,
completion: { [weak self] paywallConfig in
// Handle result
})Singletons and Shared State
Singletons provide centralized access points for app-wide services. The platform uses a controlled singleton pattern where instances are created during app launch and accessed via static properties.
Service Singletons
| Singleton | Module | Purpose | Setup |
|---|---|---|---|
Config.shared | Client | Central hub for services, publishers, and configuration | Config.setup(with:) in AppDelegate |
NetworkReachability.shared | YoullNetwork | Network connectivity status | Automatic (uses nonisolated(unsafe) static let) |
NotificationManager.shared | Client | Push notification token and permission management | Automatic |
Config is the most important singleton. It is created via a setup() call and provides lazy-loaded services:
// Client/App/Config.swift
nonisolated(unsafe) private static var _instance: Config?
public static var shared: Config {
guard let _instance else {
fatalError("Please call `setup` before accessing Config.shared")
}
return _instance
}
public static func setup(with environment: BricksNetwork.Environment, ...) {
_instance = Config(environment: environment, ...)
}
lazy public var userService = UserService(
network: bricksNetwork,
stats: ScreenConfig.profile.getProfileStatTypes().map { $0.rawValue })Config mediates access to both services (via lazy properties) and publishers (via its own CurrentValueSubject and PassthroughSubject properties). Coordinators and ViewModels access Config to get the services they need rather than creating their own instances.
Settings Actor
Settings is a Swift actor in Core that provides thread-safe persistent storage via the @UserDefault property wrapper:
// Core/App/Settings.swift
public actor Settings {
@UserDefault(key: "kAppFirstTimeOpened", defaultValue: true)
public static var appFirstTimeOpened: Bool
@UserDefault(key: "kDownloadedContent", defaultValue: [])
public static var downloadedContent: [String]
@UserDefault(key: "kDisplayedWelcomeMessage", defaultValue: false)
public static var displayedWelcomeMessage: Bool
}Settings holds ~60 properties covering app state, feature flags, onboarding progress, and cached values. Properties are accessed directly via Settings.propertyName because they use static declarations. The actor ensures thread safety for any instance methods.
Static Coordinators
Some coordinators (ContentDetailsCoordinator, SearchCoordinator, SubscriptionCoordinator, QuizCoordinator) use a shared static property to act as singleton hubs accessible from any tab. These are covered in the Coordinator Pattern page.
NotificationCenter
NotificationCenter.default (the in-process notification system) is used sparingly in the platform. Combine publishers and delegates are preferred for most communication because they provide type safety and compile-time checking.
In-Process Notifications
The primary use is the LongPressNotification, which broadcasts when a user long-presses content to trigger a context menu:
// Core/Utils/Support/NotificationCenter.swift
extension NotificationCenter {
func sendLongPressContentIdNotification(
contentId: String?,
dictionary: [String: Any]? = nil) {
guard let contentId else { return }
var info = ["contentId": contentId] as [String: Any]
if let dictionary {
info.merge(dictionary) { (_, new) in new }
}
NotificationCenter.default.post(
name: NSNotification.Name("LongPressNotification"),
object: nil,
userInfo: info)
}
}This is a broadcast pattern: any screen can post it, and the coordinator listening for it handles the context menu. NotificationCenter is appropriate here because the sender does not know or care who receives the notification.
Do not confuse NotificationCenter.default with UNUserNotificationCenter. The former is an in-process observer pattern for component communication. The latter is the iOS system framework for push and local notifications sent to the user. UNUserNotificationCenter is managed by NotificationManager.shared and EventsNotificationManager, but it is not a component communication pattern.
When to Use What
Use this decision guide when adding new communication between components:
| Dimension | Combine Publisher | Delegate | Closure | Singleton | NotificationCenter |
|---|---|---|---|---|---|
| Scope | Any | Module boundary | Local (same object graph) | App-wide | Any (broadcast) |
| Cardinality | One-to-many | One-to-one | One-to-one | Many-to-one (readers) | One-to-many |
| Lifetime | Ongoing stream | Object lifetime | One-shot | App lifetime | Ongoing |
| Direction | Source to subscribers | Child to parent | Callee to caller | Central hub to readers | Sender to any listener |
| Type safety | Strong (generics) | Strong (protocol) | Strong (closure signature) | Strong (property types) | Weak (userInfo dictionary) |
Quick decision flowchart:
- Is it a one-shot action? (navigation, completion) → Closure
- Is it an ongoing state stream? (user profile, network status) → Combine publisher
- Is it a module boundary contract? (Timer notifying host of badge earned) → Delegate protocol
- Does it need to be accessible from anywhere? (services, configuration) → Singleton with publishers
- Is it a broadcast with unknown receivers? (rare) → NotificationCenter
When in doubt, prefer Combine publishers for state and delegates for actions. NotificationCenter should be a last resort because it lacks type safety and makes data flow harder to trace.
What's Next
For Agents
Reading Order
When exploring communication patterns in the codebase, read files in this order:
Core/Data/API Service/UserService.swiftfor the centralCurrentValueSubject<YNProfile?, Never>publisher and subscription patternClient/App/Config.swiftfor the singleton hub, its publishers, and lazy service propertiesYoullNetwork/Sources/Public/NetworkReachability.swiftfor theCurrentValueSubject<Bool, Never>network status patternCore/App/Module Interface/TimerModuleInterface.swiftfor the module interface + delegate patternCore/App/Module Interface/CommunityModuleInterface.swiftfor the delegate pattern with multiple callbacksOnboarding/Public Interface/AuthenticationDelegate.swiftfor auth flow delegateTimer/Content/TimerModule.swiftfor ViewModel closure injectionCommunity/Content/CommunityCoordinator.swiftfor closure-to-delegate bridgingCore/App/Settings.swiftfor the actor-based shared state patternCore/Utils/Support/NotificationCenter.swiftfor the NotificationCenter extension
Key Files
| Pattern | File Path |
|---|---|
| Central user state publisher | Bricks/modules/Core/Core/Data/API Service/UserService.swift |
| App config singleton and publishers | Bricks/modules/Client/Client/App/Config.swift |
| Network reachability publisher | Bricks/modules/YoullNetwork/Network/Sources/Public/NetworkReachability.swift |
| Timer module interface + delegate | Bricks/modules/Core/Core/App/Module Interface/TimerModuleInterface.swift |
| Community module interface + delegate | Bricks/modules/Core/Core/App/Module Interface/CommunityModuleInterface.swift |
| Biometrics module interface | Bricks/modules/Core/Core/App/Module Interface/BiometricsModuleInterface.swift |
| Authentication delegate | Bricks/modules/Onboarding/Onboarding/Public Interface/AuthenticationDelegate.swift |
| Apple Health hybrid delegate | Bricks/modules/Core/Core/App/AppleHealthDelegate.swift |
| ViewModel closure injection (Timer) | Bricks/modules/Timer/Timer/Content/TimerModule.swift |
| ViewModel closure injection (Community) | Bricks/modules/Community/Community/Content/CommunityCoordinator.swift |
| Settings actor | Bricks/modules/Core/Core/App/Settings.swift |
| NotificationCenter extension | Bricks/modules/Core/Core/Utils/Support/NotificationCenter.swift |
| Push notification manager | Bricks/modules/Client/Client/App/NotificationManager.swift |
| Player state delegates | Bricks/modules/Player/Player/Player/Player+Protocols.swift |
Tips
- To find all publishers in a module: search for
PassthroughSubject,CurrentValueSubject, and@Publishedin the module's source files. - To trace a publisher to its subscribers: search for the publisher's property name followed by
.sinkor.receive. Subscriptions are typically set up in coordinatorstart()methods or ViewModelinit()methods. - To find which delegate protocol to implement: look at the module interface in
Core/App/Module Interface/. The delegate protocol is defined alongside the module interface protocol. - To understand closure injection: look at the coordinator or module that creates the ViewModel. The closures are set between ViewModel creation and presenting the view controller.
- Subscriptions are stored in properties named
observers(asSet<AnyCancellable>or[AnyCancellable]). Search forstore(in: &observers)to find where subscriptions are created.
Module Interfaces
How optional modules are defined as protocols in Core and injected at runtime, enabling customer apps to activate features without modifying shared code.
Home Screen
The primary content feed users see after login. Three implementations exist, from the original UIKit-based Home to the newest SwiftUI Slices with REST backend. The most complex content module in the Youll platform.