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:
- Core defines an interface protocol for each optional module. Core never imports the module's package.
- The module's package provides a concrete class that implements the interface. The module imports Core.
- The customer app creates the concrete instance and passes it to
AppCoordinatorat 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)
}| Member | Purpose |
|---|---|
delegate | Receives callbacks when the user earns a badge during a timer session |
isOnScreen | Returns 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?
}| Method | Purpose |
|---|---|
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 Method | Purpose |
|---|---|
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
| Criteria | View-Controller Factory | Coordinator Factory |
|---|---|---|
| The module appears inside other tabs | Yes | No |
| The module IS its own tab | No | Yes |
| Multiple tab coordinators need the interface | Yes | No |
| The module owns its own navigation stack | No | Yes |
| Nil handling | Optional 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 methodsStep 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 Interface | Distributed To | How |
|---|---|---|
timerModuleInterface | HomeCoordinator, ExploreCoordinator, ShowcaseCoordinator | Passed as init parameter |
tuyaModuleInterface | HomeCoordinator, ShowcaseCoordinator, ProfileCoordinator | Passed as init parameter |
biometricsModuleInterface | MainCoordinator only | createBiometricsCoordinator() calls biometricsModuleInterface?.createCoordinator(delegate: self) |
communityModuleInterface | MainCoordinator only | createCommunityCoordinator() 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 Item | Required Module | Behavior if nil |
|---|---|---|
.biometrics | biometricsModuleInterface | fatalError at tab creation |
.community | communityModuleInterface | fatalError at tab creation |
| Tabs using Timer (Home, Explore, Showcase) | timerModuleInterface | Timer features are no-ops |
| Tabs using Tuya (Home, Showcase, Profile) | tuyaModuleInterface | Tuya 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.
| Interface | Delegate Set To | Set Where | Set When |
|---|---|---|---|
timerModuleInterface.delegate | ContentDetailsCoordinator.shared | AppCoordinator.start() | During app launch, before content is shown |
| Biometrics coordinator delegate | MainCoordinator (conforms to BiometricsModuleInterfaceDelegate) | createBiometricsCoordinator() | When the biometrics tab is created |
| Community coordinator delegate | MainCoordinator (conforms to CommunityModuleInterfaceDelegate) | createCommunityCoordinator() | When the community tab is created |
| Tuya | No delegate | N/A | N/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 TuyaControllerProtocolAdding 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
| Concept | File 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 conformance | Bricks/modules/Client/Client/Coordinators/Main/MainCoordinators+TabSetup.swift |
| BaseCoordinator (Tuya convenience methods) | Bricks/modules/Client/Client/Coordinators/Tabs/BaseCoordinator.swift |
| TuyaControllerProtocol | Bricks/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
- Protocol files in
Core/Core/App/Module Interface/to understand the contracts - AppCoordinator.swift lines 24-84 for property declarations and init
- AppCoordinator.swift line 231 for timer delegate wiring
- AppCoordinator.swift
showContent()for the handoff to MainCoordinator - MainCoordinators+TabSetup.swift for distribution to tab coordinators and
createBiometricsCoordinator()/createCommunityCoordinator() - MainCoordinators+TabSetup.swift lines 288-313 for delegate conformances
- BaseCoordinator.swift for Tuya convenience methods used by tab coordinators
- A concrete module file (e.g.,
TimerModule.swift) to see how the interface is implemented
Coordinator Pattern
How navigation works in Youll apps, from the base protocol to the full coordinator hierarchy, deep link routing, and inter-coordinator communication.
Communication Patterns
How components communicate across the Youll platform using Combine publishers, delegates, closures, singletons, and NotificationCenter.