Youll
Architecture

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:

MechanismPrimary UseScope
Combine publishersReactive state streamsIntra-module and cross-module
Delegate protocolsModule boundary contractsInter-module
Closures and callbacksOne-shot actions and navigationIntra-module
Singletons and shared stateCentralized app-wide servicesCross-module
NotificationCenterBroadcast 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"| TUY

Combine 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:

TypeHolds Value?Use CaseExample
@PublishedYes (on property)ViewModel state bound to UI@Published var playerState: PlayerState
CurrentValueSubjectYes (explicit)Shared state with initial valueCurrentValueSubject<Bool, Never>(false)
PassthroughSubjectNoFire-and-forget eventsPassthroughSubject<Void, Never>()

When to choose each:

  • Use @Published in ViewModels where SwiftUI or UIKit views observe the property directly.
  • Use CurrentValueSubject when the current value matters to late subscribers (e.g., network reachability status, user profile state). New subscribers immediately receive the latest value.
  • Use PassthroughSubject for 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:

  1. Store subscriptions in a Set<AnyCancellable> property (named observers by convention). When the owning object is deallocated, all subscriptions cancel automatically.
  2. Use [weak self] in every .sink closure 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.
  3. 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 InterfaceDelegate ProtocolKey Callbacks
TimerModuleInterfaceTimerModuleDelegatetimerBadgeDidReceiveBadge(_:)
CommunityModuleInterfaceCommunityModuleInterfaceDelegatecommunityDidSelectSession(...), 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

SingletonModulePurposeSetup
Config.sharedClientCentral hub for services, publishers, and configurationConfig.setup(with:) in AppDelegate
NetworkReachability.sharedYoullNetworkNetwork connectivity statusAutomatic (uses nonisolated(unsafe) static let)
NotificationManager.sharedClientPush notification token and permission managementAutomatic

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:

DimensionCombine PublisherDelegateClosureSingletonNotificationCenter
ScopeAnyModule boundaryLocal (same object graph)App-wideAny (broadcast)
CardinalityOne-to-manyOne-to-oneOne-to-oneMany-to-one (readers)One-to-many
LifetimeOngoing streamObject lifetimeOne-shotApp lifetimeOngoing
DirectionSource to subscribersChild to parentCallee to callerCentral hub to readersSender to any listener
Type safetyStrong (generics)Strong (protocol)Strong (closure signature)Strong (property types)Weak (userInfo dictionary)

Quick decision flowchart:

  1. Is it a one-shot action? (navigation, completion) → Closure
  2. Is it an ongoing state stream? (user profile, network status) → Combine publisher
  3. Is it a module boundary contract? (Timer notifying host of badge earned) → Delegate protocol
  4. Does it need to be accessible from anywhere? (services, configuration) → Singleton with publishers
  5. 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:

  1. Core/Data/API Service/UserService.swift for the central CurrentValueSubject<YNProfile?, Never> publisher and subscription pattern
  2. Client/App/Config.swift for the singleton hub, its publishers, and lazy service properties
  3. YoullNetwork/Sources/Public/NetworkReachability.swift for the CurrentValueSubject<Bool, Never> network status pattern
  4. Core/App/Module Interface/TimerModuleInterface.swift for the module interface + delegate pattern
  5. Core/App/Module Interface/CommunityModuleInterface.swift for the delegate pattern with multiple callbacks
  6. Onboarding/Public Interface/AuthenticationDelegate.swift for auth flow delegate
  7. Timer/Content/TimerModule.swift for ViewModel closure injection
  8. Community/Content/CommunityCoordinator.swift for closure-to-delegate bridging
  9. Core/App/Settings.swift for the actor-based shared state pattern
  10. Core/Utils/Support/NotificationCenter.swift for the NotificationCenter extension

Key Files

PatternFile Path
Central user state publisherBricks/modules/Core/Core/Data/API Service/UserService.swift
App config singleton and publishersBricks/modules/Client/Client/App/Config.swift
Network reachability publisherBricks/modules/YoullNetwork/Network/Sources/Public/NetworkReachability.swift
Timer module interface + delegateBricks/modules/Core/Core/App/Module Interface/TimerModuleInterface.swift
Community module interface + delegateBricks/modules/Core/Core/App/Module Interface/CommunityModuleInterface.swift
Biometrics module interfaceBricks/modules/Core/Core/App/Module Interface/BiometricsModuleInterface.swift
Authentication delegateBricks/modules/Onboarding/Onboarding/Public Interface/AuthenticationDelegate.swift
Apple Health hybrid delegateBricks/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 actorBricks/modules/Core/Core/App/Settings.swift
NotificationCenter extensionBricks/modules/Core/Core/Utils/Support/NotificationCenter.swift
Push notification managerBricks/modules/Client/Client/App/NotificationManager.swift
Player state delegatesBricks/modules/Player/Player/Player/Player+Protocols.swift

Tips

  • To find all publishers in a module: search for PassthroughSubject, CurrentValueSubject, and @Published in the module's source files.
  • To trace a publisher to its subscribers: search for the publisher's property name followed by .sink or .receive. Subscriptions are typically set up in coordinator start() methods or ViewModel init() 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 (as Set<AnyCancellable> or [AnyCancellable]). Search for store(in: &observers) to find where subscriptions are created.

On this page