Skip to main content

Command Palette

Search for a command to run...

Single Responsibility: Breaking Up Fat ViewModels in SwiftUI

Published
8 min read

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:

  1. Data fetching: the API shape changes.

  2. Display formatting: design decides follower counts should show "1.2M" instead of "1.2K".

  3. Navigation: the app moves to a coordinator or router pattern.

  4. 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:

  1. Extract formatting into a Presenter — pure, stateless, trivially testable.

  2. Extract analytics into a Decorator around the use case — added at the Composition Root, invisible to the ViewModel.

  3. Extract navigation into a Coordinator — the ViewModel tells the coordinator what happened, the coordinator decides where to go.

  4. 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.