# 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:

```swift
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.

```swift
// 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:

```swift
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.

```swift
// 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)
    }
}
```

```swift
// 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.

```swift
// 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**.

```swift
@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

```swift
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.
