The Single Responsibility Principle says a class should have one reason to change. It’s easy to agree with in the abstract and surprisingly hard to apply when you’re building something real.
When this app was first designed, the obvious approach was one DeviceViewModel responsible for everything: loading devices, managing oven state, managing fridge state, managing dishwasher state, handling the WebSocket, and aggregating errors. One class, one place to look.
The problem is that “one place to look” quickly becomes “one place to dread.” A class responsible for three device types, a WebSocket connection, and error aggregation has at least five reasons to change:
- The oven API changes.
- Fridge door-open logic needs updating.
- Dishwasher cycle detection breaks.
- The WebSocket reconnection strategy needs improvement.
- Error handling gets more sophisticated.
Any of those changes requires opening and modifying the same massive class. And testing it means testing all concerns together — you can’t test oven state management without also instantiating the fridge and dishwasher logic.
The Coordinator pattern (as applied here) addresses this by splitting the work across focused, single-responsibility classes and composing them behind a thin facade.
Instead of one god class, you have:
OvenViewModel— owns oven state, oven commands, temperature debouncingFridgeViewModel— owns fridge/freezer state, temperature commands, door-alert logicDishwasherViewModel— owns dishwasher state, start/stop commands, cycle completion detectionDeviceViewModel— the coordinator: owns the device list, WebSocket lifecycle, error aggregation, and delegates everything else to the three children
Each child can be tested in complete isolation. You can write a test for FridgeViewModel‘s door-alert timer without touching oven or dishwasher code at all.
The coordinator solves the practical SwiftUI problem that comes with this split: views need to observe one thing, not three. If views had to inject and observe all three child ViewModels separately, you’d have to update every view that moves between device types. The coordinator acts as the single @EnvironmentObject the views observe, forwarding state from its children transparently.
Composition
The coordinator creates and holds the three child ViewModels, passing them the shared dependencies:
init(api: APIServiceProtocol = APIService.shared,
webSocket: WebSocketProviding = WebSocketService.shared,
telemetry: TelemetryService = OSLogTelemetryService.shared) {
self.ovenVM = OvenViewModel(api: api, telemetry: telemetry)
self.fridgeVM = FridgeViewModel(api: api, telemetry: telemetry)
self.dishwasherVM = DishwasherViewModel(api: api, telemetry: telemetry)
}
State is exposed as computed properties that forward directly to the children. Views read devices.ovenStates and have no idea OvenViewModel exists:
var ovenStates: [String: OvenState] { ovenVM.ovenStates }
var fridgeStates: [String: FridgeState] { fridgeVM.fridgeStates }
var dishwasherStates: [String: DishwasherState] { dishwasherVM.dishwasherStates }
Propagating changes with Combine
@Published only fires re-renders for the class it’s declared on. When OvenViewModel changes ovenStates, views observing DeviceViewModel don’t know about it — unless we tell them. Combine’s objectWillChange publisher bridges this:
ovenVM.objectWillChange
.sink { [weak self] in self?.objectWillChange.send() }
.store(in: &cancellables)
When the oven ViewModel is about to publish a change, the coordinator immediately re-publishes its own change notification. SwiftUI sees the coordinator as changing and re-renders all subscribed views. The views don’t need to know they’re talking to a coordinator — they just observe it like any other ObservableObject.
Aggregating errors
Errors from all three children are merged into a single stream using Publishers.MergeMany. If any error is an auth failure, the coordinator sets requiresReauth = true, which ContentView uses to trigger logout:
Publishers.MergeMany(
ovenVM.$error.compactMap { $0 },
fridgeVM.$error.compactMap { $0 },
dishwasherVM.$error.compactMap { $0 }
)
.sink { [weak self] appError in
self?.error = appError
if appError.isUnauthorized { self?.requiresReauth = true }
}
.store(in: &cancellables)
Testing a child in isolation
Because each child ViewModel is its own class, you can test it independently. This test verifies fridge temperature update logic without touching the coordinator, oven, or dishwasher at all:
let mock = MockAPIService()
mock.setFridgeTemperatureResult = .success(FridgeState(targetTemperature: 36, ...))
let vm = FridgeViewModel(api: mock, telemetry: MockTelemetryService())
await vm.setFridgeTemperature(deviceId: "fridge1", temperature: 36)
XCTAssertEqual(vm.fridgeStates["fridge1"]?.targetTemperature, 36)
The child ViewModels are also exposed as let properties on the coordinator, so tests that want to go deeper can reach in and inspect or call them directly:
let vm = DeviceViewModel(api: mock, webSocket: mockWS, telemetry: telemetry)
// Test just the oven child
await vm.ovenVM.toggleOven(deviceId: "oven1")
The coordinator pattern is essentially the principle of composition over inheritance applied to ViewModels: build small things that do one job well, and compose them into larger things that coordinate them.


Leave a Reply
You must be logged in to post a comment.