← Back to list

Refactoring a Production Monolith: Decoupling RevenueCat & Architecting Testable ViewModels

How I moved from a massive, tightly coupled ViewModel to a clean, layered architecture using Protocols, Inheritance, and Spy Testing.

Eytsam Elahi · 2025-12-18 04:19 · 0 claps · 3.6 min read
#ios #software-testing #in-app-purchase #revenuecat #swift-testing
Open on Medium ↗
Wiki topics: 💻 · Programming 📱 · Mobile Development 🏛️ · Architecture

Refactoring a Production Monolith: Decoupling RevenueCat & Architecting Testable ViewModels

How I moved from a massive, tightly coupled ViewModel to a clean, layered architecture using Protocols, Inheritance, and Spy Testing.

The Challenge: The “God” ViewModel

In a recent healthcare project, I was tasked with managing a complex Subscription module. It wasn’t just a simple “Buy” button. The requirements were extensive:

  • Two distinct views: A Purchase View (marketing) and a Manage Subscription View (downgrades/upgrades).
  • Eligibility checks for promotional offers.
  • Restoration logic with specific timeout handling.
  • Real-time UI updates (Toasts, Loading overlays).

Initially, I fell into the trap of the Massive ViewModel. I had a single SubscriptionViewModel directly importing the RevenueCat SDK. It was handling everything: UI state, business logic, networking, and StoreKit transactions. Any new requirement received, just dumped into the view model.

The Breaking Point: When I tried to write a Unit Test, I hit a wall. I couldn’t test the “Downgrade Logic” without actually making a network call to Apple’s sandbox servers. My code was tightly coupled to the implementation details of the SDK.

I realized i messed up things in spite of doing it fast. I knew I had to refactor.

Then i rolled up my sleeves and started to plan refactoring. Here is the architectural journey of how I decomposed this monolith into a testable, solid system.

Phase 1: The Abstraction Layer (The Client)

The first step was to stop the ViewModel from knowing who was processing the payments. I created a boundary.

Instead of calling Purchases.shared.purchase(...), I defined a protocol that outlined exactly what my app needed—nothing more, nothing less.

// The Contract: My app only cares about these 4 actions
protocol SubscriptionStoreClient {
    func fetchProducts() async throws -> ProductsSnapshot
    func purchase(_ subscription: SubscriptionModel, _ promoOffer: PromotionOfferModel?) async throws -> SubscriptionState
    func restore() async throws -> SubscriptionState
    func currentState() async throws -> SubscriptionState
}

Then, I moved the RevenueCat logic into a concrete implementation (RevenueCatStoreClient). This gave me an immediate win: The ViewModel no longer depended on the SDK.

In future, if i need to change the client from RevenueCat to StoreKit2 or some other SDK, i would easily do it without disturbing the view model or any other layer’s logic.

Phase 2: Solving Code Duplication (Base vs. Child Architecture)

Now came the tricky part. I had two views (PurchaseView and ManageView).

  • Both needed to fetch products.
  • Both needed to handle Purchasing of Subscription and “Purchase in Progress” loading states.
  • Both needed to show Error Toasts.

But PurchaseView had extra logic: it needed a restoration purchases with timer and a specific popup for failed restores.

Instead of Composition (which is often preached but can be verbose for shared UI state), I opted for Inheritance to keep my code DRY (Don’t Repeat Yourself).

1. The Base ViewModel (The Engine)

I created a BaseSubscriptionViewModel to handle the heavy lifting. Note the Dependency Injection in the init.

@MainActor
class BaseSubscriptionViewModel: ObservableObject {
    // Shared State
    @Published var isLoading: Bool = false
    @Published var purchaseInProgress: Bool = false
    @Published var subscriptionDetails: [SubscriptionModel] = []

    // The Abstract Dependency
    let store: SubscriptionStoreClient 

    // Constructor Injection: We pass the capability, not the implementation
    // In other words, Dependency Inversion
    init(store: SubscriptionStoreClient) {
        self.store = store
    }

    func purchaseSubscription(_ subscription: SubscriptionModel) async {
        purchaseInProgress = true
        do {
            // The Base VM delegates the actual work to the store client
            let status = try await store.purchase(subscription, nil)
            handlePurchaseResult(status)
        } catch {
            handleError(error)
        }
        purchaseInProgress = false
    }
}

2. The Child ViewModel (The Specialist)

My ManageSubscriptionViewModel then inherited from the Base. It didn't need to rewrite purchase logic; it focused purely on its unique requirements, like the Restore Timer.

class ManageSubscriptionViewModel: BaseSubscriptionViewModel {
    @Published var showRestoreFailedPopup: Bool = false

    // Specific Logic for the Manage Screen
    func restorePurchases() async {
        // I can access 'store' and 'isLoading' from the Base class
        self.isLoading = true 

        let status = try await store.restore()

        if status == .notSubscribed {
            self.showRestoreFailedPopup = true // Specific UI logic
        }
        self.isLoading = false
    }
}

Phase 3: The Payoff — Testing with a “Spy”

This structure unlocked powerful testing capabilities. Since I injected the SubscriptionStoreClient via init, I could create a Spy Client.

Unlike a simple Mock that just returns data, a Spy captures arguments. This allows me to verify that my ViewModel is sending the correct data to the store.

The Spy Implementation:

final class SpyStoreClient: SubscriptionStoreClient {
    // 1. Configurable Outputs (Stubbing)
    var nextPurchaseResult: SubscriptionState = .notSubscribed

    // 2. Captured Inputs (Spying)
    var capturedSubscriptionID: String?

    func purchase(_ subscription: SubscriptionModel, _ promo: PromotionOfferModel?) async throws -> SubscriptionState {
        // Capture the ID to verify later
        capturedSubscriptionID = subscription.product.identifier
        return nextPurchaseResult
    }

    // ... conform to other methods
}

The Final Test Case:

Now, writing a test is effortless. I don’t need the internet, and I don’t need RevenueCat.

@Test func testManageViewPurchaseFlow() async {
    // Arrange
    let spy = SpyStoreClient()
    spy.nextPurchaseResult = .active(MockUserSubscription()) // Force success

    let viewModel = ManageSubscriptionViewModel(store: spy)
    let testProduct = SubscriptionModel(id: "pro_yearly", price: "$99")

    // Act
    await viewModel.purchaseSubscription(testProduct)

    // Assert
    // 1. Verify Logic: Did the loading state toggle?
    #expect(viewModel.purchaseInProgress == false)

    // 2. Verify Data Flow: Did the VM send the correct ID to the Store?
    #expect(spy.capturedSubscriptionID == "pro_yearly")
}

Conclusion

Refactoring isn’t just about cleaning code; it’s about control. By decomposing my logic:

  1. I extracted the “What” (The Protocol) from the “How” (RevenueCat).
  2. I structured the “Where” using a Base/Child ViewModel pattern to share state efficiently.
  3. I verified the “When” using Spy classes that run in milliseconds.

If you are struggling with In-App Purchase logic in SwiftUI, stop patching the code. Build a wall around your SDKs, inject your dependencies, and let your tests drive the architecture.

Let me know if you have any questions in comments.

Thanks for reading.

Github LinkedIn


메타데이터
post_id
34f3fa58d0d8
slug
refactoring-a-production-monolith-decoupling-revenuecat-architecting-testable-viewmodels-34f3fa58d0d8
url
https://medium.com/@eytsam.elahi555/refactoring-a-production-monolith-decoupling-revenuecat-architecting-testable-viewmodels-34f3fa58d0d8
canonical_url
https://medium.com/@eytsam.elahi555/refactoring-a-production-monolith-decoupling-revenuecat-architecting-testable-viewmodels-34f3fa58d0d8
author_url
https://medium.com/@eytsam.elahi555
status
ok
fetched_at
2026-08-28 09:15:10