Single Responsibility: Breaking Up Fat ViewModels in SwiftUI
The Single Responsibility Principle (SRP) states that a class should have one reason to change. It's the first letter of SOLID, and arguably the one most frequently violated in SwiftUI projects.
The culprit is almost always the ViewModel.
It starts innocently: a ViewModel fetches data, formats it for display, handles user input, manages navigation state, and tracks analytics. Each of these is a separate reason to change — an API contract update, a design change, a product decision about analytics — but they all live in the same class. The ViewModel becomes a liability: hard to read, harder to test, and impossible to reuse.
This article shows how to identify SRP violations in a SwiftUI ViewModel, and how to decompose them into focused, testable units.
The Fat ViewModel
Here's a realistic example — a profile screen ViewModel that has grown organically:
final class ProfileViewModel: ObservableObject {
@Published var displayName: String = ""
@Published var avatarURL: URL?
@Published var followerCount: String = ""
@Published var isLoading = false
@Published var errorMessage: String?
@Published var navigateToEdit = false
private let userID: UUID
private let apiClient: APIClient
private let analytics: AnalyticsTracker
private let dateFormatter: DateFormatter
init(userID: UUID, apiClient: APIClient, analytics: AnalyticsTracker) {
self.userID = userID
self.apiClient = apiClient
self.analytics = analytics
self.dateFormatter = DateFormatter()
self.dateFormatter.dateStyle = .medium
}
func onAppear() async {
analytics.track("profile_viewed", properties: ["user_id": userID.uuidString])
isLoading = true
do {
let user = try await apiClient.fetchUser(id: userID)
displayName = "\(user.firstName) \(user.lastName)"
avatarURL = user.avatarURL
followerCount = formatFollowerCount(user.followerCount)
} catch {
errorMessage = "Failed to load profile."
}
isLoading = false
}
func onEditTapped() {
analytics.track("edit_profile_tapped")
navigateToEdit = true
}
private func formatFollowerCount(_ count: Int) -> String {
count >= 1000
? String(format: "%.1fK", Double(count) / 1000)
: "\(count)"
}
}
This ViewModel has at least four distinct reasons to change:
Data fetching: the API shape changes.
Display formatting: design decides follower counts should show "1.2M" instead of "1.2K".
Navigation: the app moves to a coordinator or router pattern.
Analytics: the tracking library is replaced.
Each of these is an independent concern. Bundled together, a change to any one of them requires understanding — and risking — all the others.
Step 1 — Extract Formatting into a Presenter
Formatting logic is pure and stateless. It takes a domain value and returns a display string. It deserves its own type.
// Presentation/ProfilePresenter.swift
struct ProfilePresenter {
func displayName(firstName: String, lastName: String) -> String {
"\(firstName) \(lastName)"
}
func followerCount(_ count: Int) -> String {
count >= 1_000_000 ? String(format: "%.1fM", Double(count) / 1_000_000)
: count >= 1_000 ? String(format: "%.1fK", Double(count) / 1_000)
: "\(count)"
}
}
This is now trivially testable without instantiating a ViewModel or mocking any dependency:
final class ProfilePresenterTests: XCTestCase {
private let sut = ProfilePresenter()
func test_followerCount_belowThousand() {
XCTAssertEqual(sut.followerCount(842), "842")
}
func test_followerCount_thousands() {
XCTAssertEqual(sut.followerCount(1_200), "1.2K")
}
func test_followerCount_millions() {
XCTAssertEqual(sut.followerCount(2_500_000), "2.5M")
}
func test_displayName_combinesFirstAndLast() {
XCTAssertEqual(sut.displayName(firstName: "Ada", lastName: "Lovelace"), "Ada Lovelace")
}
}
Step 2 — Extract Analytics into a Use Case Decorator
Analytics is a cross-cutting concern. It doesn't belong in the ViewModel — it belongs in a Decorator around the use case that triggers the tracking.
// Domain/UseCases/LoadProfileUseCase.swift
protocol LoadProfileUseCase {
func execute(userID: UUID) async throws -> UserProfile
}
final class RemoteLoadProfileUseCase: LoadProfileUseCase {
private let apiClient: APIClient
init(apiClient: APIClient) { self.apiClient = apiClient }
func execute(userID: UUID) async throws -> UserProfile {
try await apiClient.fetchUser(id: userID)
}
}
// Presentation/AnalyticsDecorator.swift
final class AnalyticsLoadProfileUseCase: LoadProfileUseCase {
private let decoratee: LoadProfileUseCase
private let analytics: AnalyticsTracker
init(decoratee: LoadProfileUseCase, analytics: AnalyticsTracker) {
self.decoratee = decoratee
self.analytics = analytics
}
func execute(userID: UUID) async throws -> UserProfile {
analytics.track("profile_viewed", properties: ["user_id": userID.uuidString])
return try await decoratee.execute(userID: userID)
}
}
Analytics tracking is now independently testable and doesn't pollute the ViewModel.
Step 3 — Extract Navigation into a Coordinator
Navigation state doesn't belong in the ViewModel either — the ViewModel shouldn't know where the app goes next.
// Presentation/ProfileCoordinator.swift
protocol ProfileCoordinator {
func showEditProfile()
}
The concrete implementation lives in the Composition Root or a dedicated coordinator object, not in the ViewModel.
Step 4 — The Slim ViewModel
With formatting, analytics, and navigation extracted, the ViewModel has one job: coordinate loading state and expose display data.
@MainActor
final class ProfileViewModel: ObservableObject {
@Published private(set) var displayName: String = ""
@Published private(set) var avatarURL: URL?
@Published private(set) var followerCount: String = ""
@Published private(set) var isLoading = false
@Published private(set) var errorMessage: String?
private let userID: UUID
private let loadProfile: LoadProfileUseCase
private let presenter: ProfilePresenter
private let coordinator: ProfileCoordinator
init(
userID: UUID,
loadProfile: LoadProfileUseCase,
presenter: ProfilePresenter,
coordinator: ProfileCoordinator
) {
self.userID = userID
self.loadProfile = loadProfile
self.presenter = presenter
self.coordinator = coordinator
}
func onAppear() async {
isLoading = true
errorMessage = nil
do {
let user = try await loadProfile.execute(userID: userID)
displayName = presenter.displayName(firstName: user.firstName, lastName: user.lastName)
avatarURL = user.avatarURL
followerCount = presenter.followerCount(user.followerCount)
} catch {
errorMessage = "Failed to load profile."
}
isLoading = false
}
func onEditTapped() {
coordinator.showEditProfile()
}
}
Every dependency is explicit. The ViewModel has one reason to change: how it coordinates loading state and maps domain data to display data.
Testing the Slim ViewModel
final class ProfileViewModelTests: XCTestCase {
func test_onAppear_exposesFormattedDataOnSuccess() async {
let profile = UserProfile(firstName: "Ada", lastName: "Lovelace",
avatarURL: anyURL(), followerCount: 1200)
let (sut, _, _) = makeSUT(loadResult: .success(profile))
await sut.onAppear()
XCTAssertEqual(sut.displayName, "Ada Lovelace")
XCTAssertEqual(sut.followerCount, "1.2K")
XCTAssertNil(sut.errorMessage)
XCTAssertFalse(sut.isLoading)
}
func test_onAppear_exposesErrorMessageOnFailure() async {
let (sut, _, _) = makeSUT(loadResult: .failure(anyError()))
await sut.onAppear()
XCTAssertNotNil(sut.errorMessage)
XCTAssertTrue(sut.displayName.isEmpty)
}
func test_onEditTapped_triggersCoordinator() {
let (sut, _, coordinator) = makeSUT(loadResult: .success(anyProfile()))
sut.onEditTapped()
XCTAssertTrue(coordinator.showEditProfileCalled)
}
// MARK: - Helpers
private func makeSUT(
loadResult: Result<UserProfile, Error>
) -> (ProfileViewModel, LoadProfileUseCaseStub, ProfileCoordinatorSpy) {
let useCase = LoadProfileUseCaseStub(result: loadResult)
let coordinator = ProfileCoordinatorSpy()
let sut = ProfileViewModel(
userID: UUID(),
loadProfile: useCase,
presenter: ProfilePresenter(),
coordinator: coordinator
)
return (sut, useCase, coordinator)
}
private func anyURL() -> URL { URL(string: "https://any-url.com")! }
private func anyError() -> Error { NSError(domain: "test", code: 0) }
private func anyProfile() -> UserProfile {
UserProfile(firstName: "A", lastName: "B", avatarURL: anyURL(), followerCount: 0)
}
}
private class LoadProfileUseCaseStub: LoadProfileUseCase {
private let result: Result<UserProfile, Error>
init(result: Result<UserProfile, Error>) { self.result = result }
func execute(userID: UUID) async throws -> UserProfile { try result.get() }
}
private class ProfileCoordinatorSpy: ProfileCoordinator {
var showEditProfileCalled = false
func showEditProfile() { showEditProfileCalled = true }
}
Each test verifies one behaviour. The ViewModel, presenter, and analytics decorator each have their own test suite.
Recognising SRP Violations
Ask these questions about any ViewModel:
| Question | If yes → extract to... |
|---|---|
| Does it format strings or dates? | Presenter / Formatter |
| Does it track analytics events? | AnalyticsDecorator around the use case |
| Does it set navigation flags? | Coordinator |
| Does it call multiple APIs? | Separate UseCase per operation |
| Does it manage local state (e.g. a timer)? | Dedicated StateController |
Each extraction makes the original class smaller, the new class independently testable, and the system as a whole easier to change.
What You Gain
| Fat ViewModel | Decomposed |
|---|---|
| Multiple reasons to change | One reason to change per class |
| Hard to test in isolation | Every unit tested independently |
| Formatting bugs require ViewModel knowledge | Presenter tests catch them instantly |
| Analytics change requires touching business logic | Decorator swapped at Composition Root |
| Navigation logic couples view to routing | Coordinator owns routing decisions |
Summary
The Single Responsibility Principle is not about keeping classes small for the sake of it — it's about ensuring each class changes for exactly one reason. In SwiftUI ViewModels:
Extract formatting into a
Presenter— pure, stateless, trivially testable.Extract analytics into a
Decoratoraround the use case — added at the Composition Root, invisible to the ViewModel.Extract navigation into a
Coordinator— the ViewModel tells the coordinator what happened, the coordinator decides where to go.The ViewModel's only job: coordinate loading state and map domain data to display data.
A class that does one thing is a class you can trust.





