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.
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:
- I extracted the “What” (The Protocol) from the “How” (RevenueCat).
- I structured the “Where” using a Base/Child ViewModel pattern to share state efficiently.
- 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.
메타데이터
- 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