A practical guide to building maintainable, testable iOS apps using Combine and Clean Architecture principles.
Why Unidirectional Data Flow?
Traditional iOS architectures often suffer from:
- State scattered everywhere — data lives in views, controllers, and services
- Hard to test — tightly coupled components require complex mocking
- Unpredictable updates — multiple sources can modify the same state
Unidirectional Data Flow (UDF) solves these problems by enforcing a single direction for data to travel:
User Action → Event → Action → State Change → View Update
↑ │
└──────────────────────────────────────────────┘
The view never modifies state directly. It only emits events and reads state.
The Architecture
┌─────────────────────────────────────────────────────────────┐
│ View (SwiftUI) │
│ Reads: viewState │
│ Emits: ViewEvent │
└─────────────────────────────────────────────────────────────┘
│ handle(event:) ↑ @Published viewState
↓ │
┌─────────────────────────────────────────────────────────────┐
│ ViewModel │
│ - Maps ViewEvent → DomainAction │
│ - Transforms DomainState → ViewState │
└─────────────────────────────────────────────────────────────┘
│ dispatch(action:) ↑ statePublisher
↓ │
┌─────────────────────────────────────────────────────────────┐
│ Interactor (Business Logic) │
│ - Processes actions │
│ - Mutates state │
│ - Calls services │
└─────────────────────────────────────────────────────────────┘
│ ↑
↓ │
┌─────────────────────────────────────────────────────────────┐
│ Services (Network, Storage, etc.) │
└─────────────────────────────────────────────────────────────┘
Step 1: Define the Core Protocols
These four protocols form the foundation of the architecture.
CombineViewModel
The ViewModel protocol defines what every presentation layer component must provide:
protocol CombineViewModel: ObservableObject {
associatedtype ViewState: Equatable
associatedtype ViewEvent
var viewState: ViewState { get }
func handle(event: ViewEvent)
}
-
ViewState— The current UI state (what to render) -
ViewEvent— User interactions (what happened) -
handle(event:)— Entry point for user actions
CombineInteractor
The Interactor protocol defines business logic components:
protocol CombineInteractor {
associatedtype DomainState: Equatable
associatedtype DomainAction
var statePublisher: AnyPublisher<DomainState, Never> { get }
func dispatch(action: DomainAction)
}
-
DomainState— Pure business data -
DomainAction— Commands that change state -
statePublisher— Emits state changes via Combine -
dispatch(action:)— Receives and processes actions
ViewStateReducing
Transforms domain state into UI-friendly view state:
protocol ViewStateReducing {
associatedtype DomainState
associatedtype ViewState
func reduce(domainState: DomainState) -> ViewState
}
This is a pure function — same input always produces the same output.
DomainEventActionMap
Maps user events to business actions:
protocol DomainEventActionMap {
associatedtype ViewEvent
associatedtype DomainAction
func map(event: ViewEvent) -> DomainAction?
}
Returns optional because some events may not require business logic.
Step 2: Build a Feature — User List Example
Let's build a user list feature step by step.
2.1 Define View Events
What can the user do?
enum UserListViewEvent: Equatable {
case onAppear
case onRefresh
case onUserTapped(User)
case onDeleteTapped(User)
}
These are UI-centric — they describe what happened, not what to do.
2.2 Define Domain Actions
What business operations exist?
enum UserListDomainAction: Equatable {
case loadUsers
case refreshUsers
case selectUser(User)
case deleteUser(User)
}
These are business-centric — they describe what the app should do.
2.3 Define Domain State
What data does the business logic track?
struct UserListDomainState: Equatable {
var users: [User]
var isLoading: Bool
var isRefreshing: Bool
var error: String?
var selectedUser: User?
static var initial: UserListDomainState {
UserListDomainState(
users: [],
isLoading: false,
isRefreshing: false,
error: nil,
selectedUser: nil
)
}
}
2.4 Define View State
What does the UI need to render?
struct UserListViewState: Equatable {
var users: [UserRowItem]
var isLoading: Bool
var isRefreshing: Bool
var errorMessage: String?
var showEmptyState: Bool
static var initial: UserListViewState {
UserListViewState(
users: [],
isLoading: false,
isRefreshing: false,
errorMessage: nil,
showEmptyState: false
)
}
}
struct UserRowItem: Identifiable, Equatable {
let id: String
let name: String
let email: String
let avatarURL: URL?
}
Note: UserRowItem contains only what the UI needs — not the full User model.
2.5 Create the Event-to-Action Map
struct UserListEventActionMap: DomainEventActionMap {
func map(event: UserListViewEvent) -> UserListDomainAction? {
switch event {
case .onAppear:
return .loadUsers
case .onRefresh:
return .refreshUsers
case .onUserTapped(let user):
return .selectUser(user)
case .onDeleteTapped(let user):
return .deleteUser(user)
}
}
}
2.6 Create the State Reducer
struct UserListViewStateReducer: ViewStateReducing {
func reduce(domainState: UserListDomainState) -> UserListViewState {
UserListViewState(
users: domainState.users.map { user in
UserRowItem(
id: user.id,
name: user.name,
email: user.email,
avatarURL: user.avatarURL
)
},
isLoading: domainState.isLoading,
isRefreshing: domainState.isRefreshing,
errorMessage: domainState.error,
showEmptyState: !domainState.isLoading
&& !domainState.isRefreshing
&& domainState.users.isEmpty
&& domainState.error == nil
)
}
}
2.7 Create the Interactor
final class UserListInteractor: CombineInteractor {
private let userService: UserService
private let stateSubject = CurrentValueSubject<UserListDomainState, Never>(.initial)
private var cancellables = Set<AnyCancellable>()
var statePublisher: AnyPublisher<UserListDomainState, Never> {
stateSubject.eraseToAnyPublisher()
}
init(userService: UserService) {
self.userService = userService
}
func dispatch(action: UserListDomainAction) {
switch action {
case .loadUsers:
loadUsers()
case .refreshUsers:
refreshUsers()
case .selectUser(let user):
updateState { $0.selectedUser = user }
case .deleteUser(let user):
deleteUser(user)
}
}
private func loadUsers() {
guard !stateSubject.value.isLoading else { return }
updateState { state in
state.isLoading = true
state.error = nil
}
userService.fetchUsers()
.receive(on: DispatchQueue.main)
.sink { [weak self] completion in
if case .failure(let error) = completion {
self?.updateState { state in
state.isLoading = false
state.error = error.localizedDescription
}
}
} receiveValue: { [weak self] users in
self?.updateState { state in
state.users = users
state.isLoading = false
}
}
.store(in: &cancellables)
}
private func refreshUsers() {
updateState { $0.isRefreshing = true }
userService.fetchUsers()
.receive(on: DispatchQueue.main)
.sink { [weak self] completion in
if case .failure(let error) = completion {
self?.updateState { state in
state.isRefreshing = false
state.error = error.localizedDescription
}
}
} receiveValue: { [weak self] users in
self?.updateState { state in
state.users = users
state.isRefreshing = false
}
}
.store(in: &cancellables)
}
private func deleteUser(_ user: User) {
updateState { state in
state.users.removeAll { $0.id == user.id }
}
userService.deleteUser(user)
.sink { _ in } receiveValue: { _ in }
.store(in: &cancellables)
}
// Helper: Immutable state updates
private func updateState(_ transform: (inout UserListDomainState) -> Void) {
var state = stateSubject.value
transform(&state)
stateSubject.send(state)
}
}
The updateState helper ensures state is always treated immutably.
2.8 Create the ViewModel
final class UserListViewModel: CombineViewModel, ObservableObject {
@Published private(set) var viewState: UserListViewState = .initial
@Published var selectedUser: User?
private let interactor: UserListInteractor
private let reducer: UserListViewStateReducer
private let eventMap: UserListEventActionMap
private var cancellables = Set<AnyCancellable>()
init(
interactor: UserListInteractor,
reducer: UserListViewStateReducer = UserListViewStateReducer(),
eventMap: UserListEventActionMap = UserListEventActionMap()
) {
self.interactor = interactor
self.reducer = reducer
self.eventMap = eventMap
setupBindings()
}
func handle(event: UserListViewEvent) {
// Handle navigation side effects
if case .onUserTapped(let user) = event {
selectedUser = user
}
// Map event to action and dispatch
guard let action = eventMap.map(event: event) else { return }
interactor.dispatch(action: action)
}
private func setupBindings() {
interactor.statePublisher
.map { [reducer] state in reducer.reduce(domainState: state) }
.receive(on: DispatchQueue.main)
.assign(to: &$viewState)
}
}
2.9 Create the View
struct UserListView: View {
@ObservedObject var viewModel: UserListViewModel
var body: some View {
content
.refreshable {
viewModel.handle(event: .onRefresh)
}
.onAppear {
viewModel.handle(event: .onAppear)
}
}
@ViewBuilder
private var content: some View {
if viewModel.viewState.isLoading {
ProgressView()
} else if let error = viewModel.viewState.errorMessage {
ErrorView(message: error)
} else if viewModel.viewState.showEmptyState {
EmptyStateView(message: "No users found")
} else {
userList
}
}
private var userList: some View {
List(viewModel.viewState.users) { user in
UserRowView(user: user)
.onTapGesture {
viewModel.handle(event: .onUserTapped(/* original User */))
}
.swipeActions {
Button(role: .destructive) {
viewModel.handle(event: .onDeleteTapped(/* original User */))
} label: {
Label("Delete", systemImage: "trash")
}
}
}
}
}
Step 3: Dependency Injection with ServiceLocator
A simple ServiceLocator makes testing easy:
final class ServiceLocator {
private var services: [String: Any] = [:]
func register<T>(_ type: T.Type, instance: T) {
let key = String(describing: type)
services[key] = { instance }
}
func retrieve<T>(_ type: T.Type) throws -> T {
let key = String(describing: type)
guard let factory = services[key] as? () -> T else {
throw ServiceLocatorError.notFound(key)
}
return factory()
}
}
Production Setup
// App startup
let serviceLocator = ServiceLocator()
serviceLocator.register(UserService.self, instance: LiveUserService())
// Creating a feature
let interactor = UserListInteractor(
userService: try serviceLocator.retrieve(UserService.self)
)
let viewModel = UserListViewModel(interactor: interactor)
Test Setup
func createTestServiceLocator() -> ServiceLocator {
let serviceLocator = ServiceLocator()
serviceLocator.register(UserService.self, instance: MockUserService())
return serviceLocator
}
Step 4: Testing Each Layer
Test the Event-to-Action Map
@Suite("UserListEventActionMap Tests")
struct UserListEventActionMapTests {
let sut = UserListEventActionMap()
@Test("onAppear maps to loadUsers")
func testOnAppear() {
#expect(sut.map(event: .onAppear) == .loadUsers)
}
@Test("onRefresh maps to refreshUsers")
func testOnRefresh() {
#expect(sut.map(event: .onRefresh) == .refreshUsers)
}
}
Test the Reducer
@Suite("UserListViewStateReducer Tests")
struct UserListViewStateReducerTests {
let sut = UserListViewStateReducer()
@Test("Loading state produces loading view state")
func testLoadingState() {
var domainState = UserListDomainState.initial
domainState.isLoading = true
let viewState = sut.reduce(domainState: domainState)
#expect(viewState.isLoading == true)
#expect(viewState.showEmptyState == false)
}
@Test("Empty users with no loading shows empty state")
func testEmptyState() {
let domainState = UserListDomainState.initial
let viewState = sut.reduce(domainState: domainState)
#expect(viewState.showEmptyState == true)
}
}
Test the Interactor
@Suite("UserListInteractor Tests")
@MainActor
struct UserListInteractorTests {
@Test("Load users updates state correctly")
func testLoadUsers() async throws {
let mockService = MockUserService()
mockService.fetchUsersResult = .success([User.mock])
let sut = UserListInteractor(userService: mockService)
sut.dispatch(action: .loadUsers)
try await Task.sleep(nanoseconds: 100_000_000)
let state = sut.statePublisher.value
#expect(state.users.count == 1)
#expect(state.isLoading == false)
}
}
The Complete Data Flow
Here's the full cycle when a user pulls to refresh:
1. User pulls down on the list
│
↓
2. View calls: viewModel.handle(event: .onRefresh)
│
↓
3. EventMap: .onRefresh → .refreshUsers
│
↓
4. ViewModel: interactor.dispatch(action: .refreshUsers)
│
↓
5. Interactor:
- Sets isRefreshing = true
- Calls userService.fetchUsers()
- On success: updates users, sets isRefreshing = false
│
↓
6. Interactor: stateSubject.send(newState)
│
↓
7. ViewModel binding: statePublisher → reducer.reduce() → viewState
│
↓
8. @Published viewState triggers SwiftUI update
│
↓
9. View re-renders with new data
Key Benefits
Predictable State
State only changes through actions. You can log every action to understand exactly what happened.
Easy Testing
- Reducers — Pure functions, no mocking needed
- Event maps — Pure functions, no mocking needed
- Interactors — Inject mock services
- ViewModels — Inject mock interactors
Clear Separation
- View — Only UI rendering logic
- ViewModel — Presentation logic and state transformation
- Interactor — Business logic and state management
- Services — External integrations
Scalable Patterns
Every feature follows the same structure. New team members can understand and contribute faster.
When to Use This Architecture
Good fit:
- Apps with complex state management
- Teams that value testability
- Apps that need to scale over time
- Features with multiple data sources
Overkill for:
- Simple utility apps
- Prototypes and MVPs
- Features with trivial state
Quick Reference: Adding a New Feature
- Create
FeatureViewEvent.swift— User interactions - Create
FeatureDomainAction.swift— Business commands - Create
FeatureDomainState.swift— Business data - Create
FeatureViewState.swift— UI data - Create
FeatureEventActionMap.swift— Event → Action - Create
FeatureViewStateReducer.swift— DomainState → ViewState - Create
FeatureInteractor.swift— Business logic - Create
FeatureViewModel.swift— Presentation logic - Create
FeatureView.swift— SwiftUI view - Write tests for each layer
Conclusion
Unidirectional Data Flow provides a clear, predictable way to manage state in SwiftUI apps. By separating concerns into distinct layers with well-defined protocols, you get:
- Maintainability — Each layer has a single responsibility
- Testability — Pure functions and dependency injection
- Scalability — Consistent patterns across features
- Debuggability — State changes are traceable
The initial setup requires more files than simpler architectures, but the investment pays off as your app grows in complexity.
Top comments (0)