22 KiB
Architecture
Overview
The iOS repository contains two main apps: Bitwarden Password Manager and Bitwarden Authenticator.
Password Manager App Targets
Bitwarden: The main iOS Password Manager app.BitwardenActionExtension: An Action extension that can be accessed via the system share sheet "Autofill with Bitwarden" option.BitwardenAutoFillExtension: An AutoFill Credential Provider extension which allows Bitwarden to offer up credentials for Password AutoFill.BitwardenShareExtension: A Share extension that allows creating text or file sends via the system share sheet.BitwardenWatchApp: The companion watchOS app.BitwardenShared: The main Password Manager framework containing the Core and UI layers shared between the app and extensions.
Authenticator App Targets
Authenticator: The main iOS Authenticator app.AuthenticatorShared: The main Authenticator framework containing the Core and UI layers.
Shared Frameworks
BitwardenKit: A shared framework providing common functionality across apps.BitwardenResources: A framework containing shared resources (assets, localizations, etc.).AuthenticatorBridgeKit: A framework for communication between the Password Manager and Authenticator apps.BitwardenWatchShared: Models and encoding/decoding logic for communicating between the iOS and watchOS apps.Networking: A local Swift package that implements the app's networking layer on top ofURLSession.
Test Helpers
GlobalTestHelpers: Shared functionality between the app's test targets.BitwardenKitMocks: Mock implementations for BitwardenKit components.AuthenticatorBridgeKitMocks: Mock implementations for AuthenticatorBridgeKit components.TestHelpers: Additional test utilities and helpers.ViewInspectorTestHelpers: ViewInspector-specific test helpers for UI testing.
Architecture Structure
Most of the app's functionality is implemented in the BitwardenShared and AuthenticatorShared frameworks. The files within these frameworks are mainly split up between two top-level folders, Core and UI. Each of these folders is then subdivided into the following folders:
AuthAutofillPlatformToolsVault
These folders align with the CODEOWNERS file for the project. One MUST not add additional direct subfolders to Core or UI. While this top-level structure is deliberately inflexible, the folder structure within the subfolders are not specifically prescribed.
The responsibilities of the core layer are to manage the storage and retrieval of data from low-level sources (such as from the network, persistence, or Bitwarden SDK) and to expose them in a more ready-to-consume manner by the UI layer via "repository" and "service" classes. The UI layer is then responsible for any final processing of this data for display in the UI as well as receiving events from the UI and updating the tracked state accordingly.
Core Layer
The core layer is where all the UI-independent data is stored and retrieved. It consists of both raw data sources as well as higher-level "repository" and "service" classes.
Models
The lowest level of the core layer are the data model objects. These are the raw sources of data that include data retrieved or sent via network requests, data persisted with CoreData, and data that is used to interact with the Bitwarden SDK.
The models are roughly organized based on their use and type:
Domain: Models that represent the main data types within the app.Enum: Enumeration model types.Request: Request models are data models used in the body of an API request.Response: Response models are typically the top-level data models that are decoded from an API response. These models may utilize domain and enum models that are shared between responses.
Data Stores
Data stores are responsible for persisting data to Core Data, Keychain, and UserDefaults. This is implemented by the following:
- AppSettingStore: UserDefaults persistence.
- DataStore: CoreData persistence.
- KeychainRepository: Keychain persistence.
Services
Services represent the middle layer of the core layer. While some services may depend on other services or lower-level data stores (e.g. CipherService), others are wrappers around OS-level functionality (e.g. NFCReaderService). The commonality amongst the services is that they tend to have a single discrete responsibility. These classes may exist solely in the core layer for use inside a repository or another service, like CipherService, or may be exposed directly to the UI layer, like NFCReaderService.
Repositories
Repositories are at the outermost layer of the core layer. Repositories are usually composed of one or more services, and in rare cases other repositories. Repositories are meant to be exposed directly to the UI layer. They synthesize data from multiple sources and combine various asynchronous requests as necessary to expose data to the UI layer in a more appropriate form. These classes tend to have broad responsibilities that generally cover a major domain of the app, such as authentication (AuthRepository) or vault access (VaultRepository).
Dependency Injection
All services are contained within the ServiceContainer. This allows services to be passed around the app and injected into the UI layer with minimal changes when a new dependency is added.
To make it clearer which coordinators and processors are using which dependencies, there is a Services typealias that the ServiceContainer conforms to. This typealias uses protocol composition to declare the list of dependencies in the ServiceContainer that may be used in the UI layer. These protocols use a naming pattern of Has<Service>. The Services typealias only needs to contain the dependencies that need to be accessed outside of the data layer (e.g. A store may only need to be accessed by services or repositories in the core layer and wouldn't need to be exposed to the UI layer in the Services typealias. It would still be contained within the service container and injected directly into services or repositories at initialization.).
Components in the UI layer can declare a similar Services typealias containing the list of dependencies that need to be injected into the component. This allows a single service container instance to be passed around the UI layer but limits components to accessing the predefined list of dependencies.
The following example shows a Services typealias using protocol composition that the ServiceContainer conforms to. The types that are used within Services and ServiceContainer (e.g. ExampleRepository, ExampleStore, and ExampleService) should be protocols so they can be mocked for testing.
Show example
typealias Services = HasExampleRepository
& HasExampleService
protocol HasExampleRepository {
var exampleRepository: ExampleRepository { get }
}
protocol HasExampleService {
var exampleService: ExampleService { get }
}
final class ServiceContainer: Services {
let exampleService: ExampleService
let exampleStore: ExampleStore
let exampleRepository: ExampleRepository
}
final class ExampleCoordinator: Coordinator {
typealias Services = HasExampleRepository
private let services: Services
init(services: Services) {
self.services = services
}
}
UI Layer
The UI layer utilizes a unidirectional data flow pattern that is based on coordinators and processors.
Module
Modules are used to build coordinators.
Each coordinator that can navigate to child coordinators will use its module to instantiate the child coordinator. A coordinator will have a module protocol that defines the child coordinators that could be built by that coordinator. The module protocols are implemented by the DefaultAppModule. The coordinator's module can be mocked during testing to test child coordinator creation.
The DefaultAppModule provides a single entry point for creating all coordinators in the application. This allows the services in the service container to be injected into new coordinators without requiring the services to be passed throughout the coordinator hierarchy.
Coordinator
Coordinators create processors and views to facilitate navigation between views or flows within the application. In general, a coordinator is responsible for managing navigation within a single container view controller (e.g. UINavigationController, UITabBarController).
Occasionally, a single coordinator can manage the navigation within an entire feature flow (e.g. AuthCoordinator handles the navigation between authentication views). Once a flow becomes complex enough, or the container view controller changes (e.g. a UINavigationController is presented which has its own set of flows), the coordinator can create and display a child coordinator. An example of this is how VaultCoordinator handles navigation within the vault tab and VaultItemCoordinator handles navigation for viewing, adding, or editing vault items in a presented UINavigationController.
Coordinators should remain free of business logic. Logic should be handled in the processor prior to navigation or in the new processor after navigation occurs. In rare cases where there is a lot of logic around what view should come next, a router can be implemented to work alongside the coordinator. An example of this is AuthRouter, which makes decisions around which route should be navigated to next within the authentication flow.
Routes and Events
Coordinators operate on a set of routes and, optionally, events.
Routes are implemented as an enumeration and define the set of screens that the coordinator can navigate to. Most routes will correspond to a specific view in the app, but routes can also be a more generic action that that coordinator needs to take (e.g. dismissing a presented coordinator flow when the flow completes).
Events define a set of actions that occur in a processor that requires navigation, but without the processor prescribing the specific screen that the coordinator navigates to. These will commonly be used in combination with a router and allows the router to determine the next screen that should be shown before any navigation occurs. Some examples of events are app startup (which screen should be shown first?), switching accounts (does the vault need to be unlocked first?), and logout (are there other accounts to switch to after logout?). Events are also implemented as an enumeration.
Processor
Processors manage the state and business logic for a view. Processors are the only location where state mutation occurs. Processors receive actions and effects from the view, performs any business logic and then updates the state. Whenever a processor updates its state, it automatically publishes the new state to the view.
If a change in state necessitates a navigation change, the processor requests that its coordinator handle the navigation to a new view.
State
State represents the data and configuration needed to perform the processor's logic and render the UI for a feature. All information needed to configure the UI associated with the processor should be included in the state.
View
A view renders the UI based on its state. The state within a view is managed by a Store. The store is the connection between the processor and the view. Views never mutate state directly; instead, they send actions or perform effects via the store which are forwarded back to the processor. Views are updated by the store anytime the state changes.
Actions and Effects
Actions are triggered by the view and represent interactions with the processor that could potentially cause an update to state or navigation. These will usually be sent because of some user interaction with the UI, such as a button being tapped or a text field's value changing. Actions are processed synchronously by the processor.
Effects are like actions, but usually represent side-effects where the processor needs to communicate with an external repository or service. Effects are asynchronous and may perform some work before updating the state, examples which include loading or subscribing to data for the view or making API requests. Long-running tasks such as subscribing to an AsyncPublisher should use the task modifier on the view so that the task is cancelled if the view disappears before the task completes.
Actions and effects are implemented as enumerations so that adding a new action or effect ensures the processor is updated to handle the new case.
Example
The following example demonstrates the above components in the architecture.
- The coordinator creates the processor and view and handles navigation within its navigator (in this case a StackNavigator which is implemented by a
UINavigationController). - The processor and view share:
- State containing the properties that are used to configure the UI:
dataandisToggleOn. - Actions that are used by the processor to update the state (
updateToggle) or trigger navigation (nextExample). - Effects that perform asynchronous work:
loadDatafetches some data to display from the repository.
- State containing the properties that are used to configure the UI:
- The view builds the UI based on the current state in the store and notifies the processor via actions or effects as interactions occur.
- The processor responds to actions by updating the state or triggering navigation via the coordinator. Effects are used to kick off asynchronous work.
Show example
enum ExampleRoute {
case example
case nextExample
}
final class ExampleCoordinator: Coordinator, HasStackNavigator {
typealias Event = Void
typealias Services = HasExampleRepository
private let services: Services
private(set) weak var stackNavigator: StackNavigator?
init(services: Services, stackNavigator: StackNavigator?) {
self.services = services
self.stackNavigator = stackNavigator
}
func start() {}
func navigate(to route: ExampleRoute, context: AnyObject?) {
switch route {
case .example:
showExample()
case .nextExample:
// ...
}
}
private func showExample() {
let processor = ExampleProcessor(
coordinator: asAnyCoordinator(),
services: services,
state: ExampleState()
)
let view = ExampleView(store: Store(processor: processor))
stackNavigator?.push(view)
}
}
struct ExampleState: Equatable {
var data: String?
var isToggleOn = false
}
enum ExampleAction: Equatable {
case nextExample
case updateToggle(Bool)
}
enum ExampleEffect: Equatable {
case loadData
}
final class ExampleProcessor: StateProcessor<ExampleState, ExampleAction, ExampleEffect> {
typealias Services = HasExampleRepository
private var coordinator: any Coordinator<ExampleRoute, Void>
private var services: Services
init(coordinator: any Coordinator<ExampleRoute, Void>, services: Services, state: ExampleState) {
self.coordinator = coordinator
self.services = services
super.init(state: state)
}
override func receive(_ action: ExampleAction) {
switch action {
case .nextExample:
coordinator.navigate(to: .nextExample)
case let .updateToggle(newValue):
state.isToggleOn = newValue
}
}
override func perform(_ effect: ExampleEffect) async {
switch effect {
case .loadData:
await loadData()
}
}
private func loadData() async {
do {
state.data = try await services.exampleRepository.loadData()
} catch {
// Handle errors.
}
}
}
struct ExampleView: View {
@ObservedObject var store: Store<ExampleState, ExampleAction, ExampleEffect>
var body: some View {
VStack {
if let data = store.state.data {
Text(data)
}
Toggle(
Localizations.toggleExample,
isOn: store.binding(
get: \.isToggleOn,
send: ExampleAction.updateToggle
)
)
Button(Localizations.next) {
store.send(.nextExample)
}
}
.task {
await store.perform(.loadData)
}
}
}
Test Architecture
Testing Philosophy
The test architecture mirrors the layered structure of the application, ensuring that each layer can be tested in isolation through dependency injection and protocol-based abstractions.
Test Layer Alignment
Core Layer Testing
Tests for the core layer focus on data integrity, business logic, and repository/service behavior:
- Repositories: Test data synthesis from multiple sources, error handling, and asynchronous operations
- Services: Test discrete responsibilities and interactions with lower-level stores
- Data Stores: Test persistence operations (CoreData, Keychain, UserDefaults)
- Models: Test data transformations, encoding/decoding, and domain logic
Core layer tests should use mocked dependencies to isolate the system under test from external services.
UI Layer Testing
Tests for the UI layer validate the unidirectional data flow and state management:
- Processors: Test state mutations in response to actions, effect handling, and coordinator navigation requests
- Coordinators: Test route handling and child coordinator creation using mocked modules
- Views: Test UI rendering based on state and user interaction handling
- State: Test state equality and state transformations
UI layer tests leverage the Store abstraction to verify the connection between processors and views.
Testing Strategies by Component Type
The architecture employs three complementary testing strategies:
- Logic Testing: Unit tests validate business logic, state management, and data transformations using protocol mocks
- Interaction Testing: View inspector tests verify user interactions send correct actions/effects through the store
- Visual Testing: Snapshot tests capture visual regressions across different display modes and accessibility settings
Test Organization
Test files are co-located with the code they test, maintaining the same folder structure as the main codebase. This organization:
- Makes it easy to find tests for a given type
- Ensures tests evolve alongside the code
- Reinforces the architectural boundaries (Auth, Autofill, Platform, Tools, Vault)
Dependency Mocking
The architecture's use of protocol composition in the Services typealias enables comprehensive mocking:
- All services and repositories are defined as protocols
- Mock implementations are generated using Sourcery's
AutoMockableannotation - Coordinators can be tested with mocked modules to verify navigation logic
- Processors can be tested with mocked services to verify state management
Test Isolation
Each architectural layer can be tested independently:
- Core layer tests mock network responses, SDK interactions, and persistence layers
- UI layer tests mock repositories and services from the core layer
- Integration points between layers are tested by verifying protocol conformance
This isolation enables fast, reliable tests that pinpoint failures to specific architectural components.