Youll
Architecture

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.

Why Module Interfaces

Most product modules (Home, Explore, Journeys, Library, Events, Activity, Meals, Quiz) live entirely inside Core and Client. They are always compiled into every customer app and activated or hidden through tab selection, feature flags, and Settings. No special wiring is needed.

Four modules are different. Timer, Tuya, Biometrics, and Community each live in their own Swift package with independent dependencies. Including them is optional at the code level, not just the configuration level. A wellness app might want Biometrics but not Tuya. A hardware app might want Tuya and Timer but not Community.

Module interfaces solve this with a protocol boundary:

  1. Core defines an interface protocol for each optional module. Core never imports the module's package.
  2. The module's package provides a concrete class that implements the interface. The module imports Core.
  3. The customer app creates the concrete instance and passes it to AppCoordinator at launch. AppCoordinator only knows the protocol type.

This means optional modules can be compiled, linked, and injected independently. If a customer app does not need Community, it never creates a CommunityModule and passes nil. Bricks handles the nil gracefully (with one important exception documented below).

The Four Interfaces

All four interface protocols live in Core/Core/App/Module Interface/. Each is annotated with @MainActor because the methods create or present UIKit view controllers.

View-Controller Factory Style

Timer and Tuya use a view-controller factory pattern. The caller passes a UINavigationController and the module presents its screens within that existing navigation stack. These modules appear inside other tabs (Home, Explore, Showcase, Profile) rather than owning their own tab.

TimerModuleInterface

// Core/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)
}
MemberPurpose
delegateReceives callbacks when the user earns a badge during a timer session
isOnScreenReturns whether the timer UI is currently displayed. Used by MainCoordinator to suppress the offline banner while the timer is active
showTimerSettingsController(from:tuyaController:)Pushes the timer settings screen onto the given navigation stack
showTimerViewController(from:tuyaController:)Pushes the active timer screen onto the given navigation stack

Both presentation methods accept an optional TuyaControllerProtocol?. This enables Timer to control IoT devices during sessions when Tuya is also active. See Cross-Module Dependencies below.

TuyaModuleInterface

// Core/Core/App/Module Interface/TuyaModuleInterface.swift

@MainActor
public protocol TuyaModuleInterface {
  func getTuyaController() -> TuyaControllerProtocol?
  func createTuyaController(
    for device: TuyaDevice,
    onClose: (() -> Void)?,
    onInstalationInstructions: ((_ tag: String) -> Void)?) -> AppearanceCoordinatedViewController?
  func createTuyaPairController(onClose: (() -> Void)?) -> UIViewController?
  func createTuyaMaintenanceController(for reminderType: String) -> UIViewController?
}
MethodPurpose
getTuyaController()Returns the underlying TuyaControllerProtocol for data access (device lists, connection status). Also passed to Timer methods.
createTuyaController(for:onClose:onInstalationInstructions:)Creates a device control screen for a specific TuyaDevice
createTuyaPairController(onClose:)Creates the BLE/WiFi pairing flow screen
createTuyaMaintenanceController(for:)Creates a maintenance reminder screen for a given reminder type

All create* methods return view controllers that the caller pushes or presents. The module does not own navigation.

TuyaModuleInterface is the only interface that does not conform to AnyObject. The other three require class semantics via : AnyObject. In practice, all four concrete implementations are classes. When creating a new module interface, conform to AnyObject for consistency.

Coordinator Factory Style

Biometrics and Community use a coordinator factory pattern. The module returns a Coordinator object that becomes a first-class tab with its own navigation stack. These modules ARE an entire tab rather than appearing inside other tabs.

BiometricsModuleInterface

// Core/Core/App/Module Interface/BiometricsModuleInterface.swift

@MainActor
public protocol BiometricsModuleInterface: AnyObject {
  func createCoordinator(delegate: BiometricsModuleInterfaceDelegate?) -> Coordinator
}

@MainActor
public protocol BiometricsModuleInterfaceDelegate: AnyObject {
}

The delegate protocol is intentionally empty. It exists for future extensibility. Biometrics data flows back through other channels (HealthKit delegates, Combine publishers on Config) rather than through this delegate.

CommunityModuleInterface

// Core/Core/App/Module Interface/CommunityModuleInterface.swift

@MainActor
public protocol CommunityModuleInterface: AnyObject {
  func createCoordinator(delegate: CommunityModuleInterfaceDelegate?) -> Coordinator
}

@MainActor
public protocol CommunityModuleInterfaceDelegate: AnyObject {
  func communityDidSelectSession(_ session: ContentItem, autoPlayContentIDs: [String])
  func communityDidSelectJourney(_ journey: JourneyItem, navigationController: UINavigationController)
  func communityDidSelectURL(_ url: URL)
}
Delegate MethodPurpose
communityDidSelectSession(_:autoPlayContentIDs:)User tapped content in a community post. The delegate fetches the full content and navigates to the player.
communityDidSelectJourney(_:navigationController:)User tapped a journey in a community post. The delegate navigates to the journey detail screen.
communityDidSelectURL(_:)User tapped an external URL. The delegate opens a Safari view controller.

These delegate methods handle cross-module navigation: when users interact with content inside the Community tab, the orchestration layer (MainCoordinator) routes them to the correct destination in another tab.

Choosing a Style

CriteriaView-Controller FactoryCoordinator Factory
The module appears inside other tabsYesNo
The module IS its own tabNoYes
Multiple tab coordinators need the interfaceYesNo
The module owns its own navigation stackNoYes
Nil handlingOptional chaining (silent no-op)Must inject if tab is included (crash otherwise)

The Injection Chain

Module interfaces flow through four layers from creation to usage.

SceneDelegate
    │ creates concrete instances

AppCoordinator
    │ stores as optional properties
    │ (showContent())

MainCoordinator
    │ distributes to tab coordinators

Tab Coordinators (Home, Explore, Showcase, Profile)
    use optional chaining to call interface methods

Step 1: SceneDelegate Creates Module Instances

The customer app creates concrete module instances after Config.setup() completes. Each module takes a module-specific screen config factory and shared services:

// SceneDelegate.swift (customer app)

let timerModule = TimerModule(
    screenConfigFactory: YourTimerConfigFactory(),
    analytics: Client.AnalyticsManager.shared,
    requiredNetworkCheckPublisher: config.requiredNetworkCheckPublisher,
    contentService: ContentService(network: config.bricksNetwork),
    userService: config.userService)

let tuyaModule = TuyaModule(
    screenConfigFactory: YourTuyaConfigFactory.instance)

let biometricsModule = BiometricsModule(
    screenConfigFactory: YourBiometricsConfigFactory(),
    uiConfigurator: UIConfigurator.shared,
    userService: config.userService,
    appleHealthDelegate: YourAppleHealthManager.shared)

Each module has its own ScreenConfigFactoryType protocol that the customer app implements. These factories provide the design tokens and configuration specific to that module's UI.

Step 2: AppCoordinator Stores as Optionals

All four interfaces are optional properties with nil defaults on the initializer:

// Client/Coordinators/AppCoordinator.swift

public class AppCoordinator: ParentCoordinator {
  var timerModuleInterface: TimerModuleInterface?
  var tuyaModuleInterface: TuyaModuleInterface?
  var biometricsModuleInterface: BiometricsModuleInterface?
  var communityModuleInterface: CommunityModuleInterface?

  public init(window: UIWindow, config: ConfigType,
              showSplashVideo: Bool = true,
              splashScreen: UIViewController? = nil,
              splashScreenController: SplashScreenController? = nil,
              timerModuleInterface: TimerModuleInterface? = nil,
              tuyaModuleInterface: TuyaModuleInterface? = nil,
              biometricsModuleInterface: BiometricsModuleInterface? = nil,
              communityModuleInterface: CommunityModuleInterface? = nil,
              customSubscriptionsManager: CustomSubscriptionsManager? = nil) {
    // ...
  }
}

Pass nil (or omit the parameter) for any module your app does not use.

Step 3: showContent() Passes to MainCoordinator

When the user logs in (or is already authenticated), AppCoordinator.showContent() creates MainCoordinator and passes all four interfaces:

// AppCoordinator.swift

private func showContent() {
  let coordinator = MainCoordinator(
    config: config,
    customSubscriptionsManager: customSubscriptionsManager,
    timerModuleInterface: timerModuleInterface,
    tuyaModuleInterface: tuyaModuleInterface,
    biometricsModuleInterface: biometricsModuleInterface,
    communityModuleInterface: communityModuleInterface)
  coordinator.start()
  addChildCoordinator(coordinator, flow: .main)
  window.rootViewController = coordinator.rootController
}

Step 4: MainCoordinator Distributes to Tab Coordinators

MainCoordinator passes view-controller factory interfaces to the tab coordinators that need them. Coordinator factory interfaces are consumed directly by MainCoordinator when creating tab coordinators.

Module InterfaceDistributed ToHow
timerModuleInterfaceHomeCoordinator, ExploreCoordinator, ShowcaseCoordinatorPassed as init parameter
tuyaModuleInterfaceHomeCoordinator, ShowcaseCoordinator, ProfileCoordinatorPassed as init parameter
biometricsModuleInterfaceMainCoordinator onlycreateBiometricsCoordinator() calls biometricsModuleInterface?.createCoordinator(delegate: self)
communityModuleInterfaceMainCoordinator onlycreateCommunityCoordinator() calls communityModuleInterface?.createCoordinator(delegate: self)

Nil Handling

What happens when a module interface is nil depends on the interface style.

View-Controller Factory (Timer, Tuya): Silent No-Op

When timerModuleInterface or tuyaModuleInterface is nil, all calls through optional chaining do nothing:

// This is safe. If timerModuleInterface is nil, nothing happens.
timerModuleInterface?.showTimerSettingsController(
    from: navigationController,
    tuyaController: tuyaModuleInterface?.getTuyaController())

Whether the UI elements that trigger these calls (timer buttons, device cards) are visible depends on the customer app's screen config factory and feature flags, not on the interface itself. A customer app that does not inject a timer module should also configure its screen config to hide timer-related UI elements.

Coordinator Factory (Biometrics, Community): Crash If Tab Included

When biometricsModuleInterface or communityModuleInterface is nil and the corresponding tab is included in makeTabItems(), the app crashes with fatalError:

// MainCoordinators+TabSetup.swift

private func createBiometricsCoordinator() -> Coordinator {
  guard let coordinator = biometricsModuleInterface?.createCoordinator(delegate: self) else {
    fatalError("Please implement biometrics correctly.")
  }
  // ...
}

If your app includes .biometrics or .community in makeTabItems(), you must inject the corresponding module interface into AppCoordinator. Failing to do so causes a fatalError at launch after login, when MainCoordinator tries to create the tab coordinator. There is no graceful fallback. The error message may be misleading ("createCoordinator returned nil") because the actual cause is the module interface itself being nil, not the coordinator creation failing.

Tab ItemRequired ModuleBehavior if nil
.biometricsbiometricsModuleInterfacefatalError at tab creation
.communitycommunityModuleInterfacefatalError at tab creation
Tabs using Timer (Home, Explore, Showcase)timerModuleInterfaceTimer features are no-ops
Tabs using Tuya (Home, Showcase, Profile)tuyaModuleInterfaceTuya features silently skipped

Delegate Wiring

Three of the four module interfaces use delegates for callbacks. The delegates are wired at different points in the lifecycle.

InterfaceDelegate Set ToSet WhereSet When
timerModuleInterface.delegateContentDetailsCoordinator.sharedAppCoordinator.start()During app launch, before content is shown
Biometrics coordinator delegateMainCoordinator (conforms to BiometricsModuleInterfaceDelegate)createBiometricsCoordinator()When the biometrics tab is created
Community coordinator delegateMainCoordinator (conforms to CommunityModuleInterfaceDelegate)createCommunityCoordinator()When the community tab is created
TuyaNo delegateN/AN/A

If a delegate is not set:

  • Timer: Badge callbacks never fire. Users complete timer sessions but the badge-earning notification is silently dropped.
  • Community: Cross-module navigation breaks. Tapping content, journeys, or URLs inside community posts does nothing.
  • Biometrics: No current impact (delegate is empty), but future delegate methods would silently fail.

Cross-Module Dependencies

Timer and Tuya have a cross-module coupling through TuyaControllerProtocol.

Both of Timer's presentation methods accept an optional TuyaControllerProtocol? parameter:

func showTimerSettingsController(from navigationController: UINavigationController,
                                 tuyaController: TuyaControllerProtocol?)

When a customer app has both Timer and Tuya active, the tab coordinators pass the Tuya controller to Timer:

// In a tab coordinator
timerModuleInterface?.showTimerSettingsController(
    from: navigationController,
    tuyaController: tuyaModuleInterface?.getTuyaController())

This allows Timer to control IoT devices during sessions (adjusting lights, diffusers, etc.). If Tuya is not active, tuyaController is nil and Timer's Tuya-related features are disabled.

The coupling works without a direct dependency between the Timer and Tuya packages because TuyaControllerProtocol is defined in Core:

Core
├── defines TuyaControllerProtocol
├── defines TimerModuleInterface (references TuyaControllerProtocol)
└── defines TuyaModuleInterface (returns TuyaControllerProtocol)

Timer (imports Core, NOT Tuya)
└── implements TimerModuleInterface, uses TuyaControllerProtocol parameter

Tuya (imports Core, NOT Timer)
└── implements TuyaModuleInterface, provides TuyaControllerProtocol

Adding a New Module Interface

Common Steps (Both Styles)

1. Define the interface protocol in Core.

Create a new file in Core/Core/App/Module Interface/:

// Core/Core/App/Module Interface/YourModuleInterface.swift

@MainActor
public protocol YourModuleInterface: AnyObject {
  // Style A: view-controller factory methods
  // Style B: func createCoordinator(delegate:) -> Coordinator
}

2. Define a delegate protocol.

Even if the delegate has no methods yet, define it for future extensibility:

@MainActor
public protocol YourModuleInterfaceDelegate: AnyObject {
  // Add methods as needed for cross-module callbacks
}

3. Add the optional property to AppCoordinator.

In AppCoordinator.swift, add the property and init parameter:

var yourModuleInterface: YourModuleInterface?

public init(...,
            yourModuleInterface: YourModuleInterface? = nil,
            ...) {
  self.yourModuleInterface = yourModuleInterface
  // ...
}

4. Thread through showContent() to MainCoordinator.

Pass the interface when creating MainCoordinator in showContent():

let coordinator = MainCoordinator(
  config: config,
  // ... existing interfaces ...
  yourModuleInterface: yourModuleInterface)

Add the corresponding property and init parameter to MainCoordinator as well.

5. Create the concrete implementation in your module's package.

In your module's Swift package:

// YourModule/YourModule/Content/YourModule.swift

import Core

public class YourModule: YourModuleInterface {
  private let screenConfigFactory: YourScreenConfigFactoryType

  public init(screenConfigFactory: YourScreenConfigFactoryType, /* other deps */) {
    self.screenConfigFactory = screenConfigFactory
  }

  // Implement interface methods
}

Additional Steps for Coordinator Factory (Style B)

If your module is a standalone tab:

6. Add a CoordinatorFlow case in Client/Coordinators/Coordinator.swift.

7. Add a TabItem case in Client/Coordinators/Static Coordinators/ContentDetails/Utils/TabBarItem.swift.

8. Add a createYourModuleCoordinator() method in MainCoordinators+TabSetup.swift:

private func createYourModuleCoordinator() -> Coordinator {
  guard let coordinator = yourModuleInterface?.createCoordinator(delegate: self) else {
    fatalError("yourModuleInterface is nil but .yourModule tab is in makeTabItems()")
  }
  coordinator.start()
  addChildCoordinator(coordinator, flow: .yourModule)
  return coordinator
}

9. Add the case to createCoordinator(item:) in the same file.

10. Conform MainCoordinator to your delegate protocol in MainCoordinators+TabSetup.swift.

Additional Steps for View-Controller Factory (Style A)

If your module appears inside other tabs:

6. Pass the interface to the tab coordinators that need it (Home, Explore, Showcase, Profile) in their createXxxCoordinator() methods.

7. Use optional chaining when calling interface methods from tab coordinators.

What's Next

For Agents

Key Files

ConceptFile Path
Interface protocols (all four)Bricks/modules/Core/Core/App/Module Interface/*.swift
AppCoordinator (stores + passes interfaces)Bricks/modules/Client/Client/Coordinators/AppCoordinator.swift
MainCoordinator (distributes interfaces)Bricks/modules/Client/Client/Coordinators/Main/MainCoordinator.swift
Tab creation + delegate conformanceBricks/modules/Client/Client/Coordinators/Main/MainCoordinators+TabSetup.swift
BaseCoordinator (Tuya convenience methods)Bricks/modules/Client/Client/Coordinators/Tabs/BaseCoordinator.swift
TuyaControllerProtocolBricks/modules/Core/Core/Data/Tuya/TuyaControllerProtocol.swift
TimerModule (concrete)Bricks/modules/Timer/Timer/Content/TimerModule.swift
TuyaModule (concrete)Bricks/modules/Tuya/Tuya/Content/TuyaModule.swift
BiometricsModule (concrete)Bricks/modules/Biometrics/Biometrics/Content/BiometricsModule.swift
CommunityModule (concrete)Bricks/modules/Community/Community/Content/CommunityModule.swift

Reading Order

  1. Protocol files in Core/Core/App/Module Interface/ to understand the contracts
  2. AppCoordinator.swift lines 24-84 for property declarations and init
  3. AppCoordinator.swift line 231 for timer delegate wiring
  4. AppCoordinator.swift showContent() for the handoff to MainCoordinator
  5. MainCoordinators+TabSetup.swift for distribution to tab coordinators and createBiometricsCoordinator() / createCommunityCoordinator()
  6. MainCoordinators+TabSetup.swift lines 288-313 for delegate conformances
  7. BaseCoordinator.swift for Tuya convenience methods used by tab coordinators
  8. A concrete module file (e.g., TimerModule.swift) to see how the interface is implemented

On this page