Following up on #35, where you mentioned preferring an MVVM pass over TCA — and specifically that you'd want to do it properly rather than ending up with half the app on one paradigm and half on another.
Here's a concrete plan for migrating Forji to MVVM incrementally, one feature at a time, with the app working in mixed state throughout.
The pattern
Each feature gets a @MainActor @Observable view model that owns the feature's state and async work. The view becomes a thin binding layer. No new dependencies — just Swift's Observation framework, async/await, and protocols you already have.
Before (current pattern in NotificationsOverviewView)
struct NotificationsOverviewView: View {
@State private var pagination = PaginationState<NotificationThread>()
@State private var errorMessage: String?
@State private var showError = false
var body: some View {
List { /* ... */ }
.task { await loadNotifications() }
}
private func loadNotifications() async {
do {
let threads = try await notificationService.list(...)
pagination.items = threads
} catch {
errorMessage = error.localizedDescription
showError = true
}
}
private func markAsRead(id: Int64) async {
try? await notificationService.markAsRead(id: id)
pagination.items.removeAll { 0ドル.id == id }
NotificationCenter.default.post(name: .notificationsDidChange, object: nil)
}
}
After (MVVM with @Observable)
@MainActor @Observable
final class NotificationsViewModel {
private(set) var threads: [NotificationThread] = []
private(set) var isLoading = false
var error: Error?
private let service: NotificationServiceProtocol
init(service: NotificationServiceProtocol) {
self.service = service
}
func load() async {
isLoading = true
defer { isLoading = false }
do {
threads = try await service.list(...)
} catch {
self.error = error
}
}
func markAsRead(_ thread: NotificationThread) async {
try? await service.markAsRead(id: thread.id)
threads.removeAll { 0ドル.id == thread.id }
}
}
struct NotificationsOverviewView: View {
@State private var viewModel: NotificationsViewModel
init(service: NotificationServiceProtocol) {
_viewModel = State(initialValue: NotificationsViewModel(service: service))
}
var body: some View {
List { /* bind to viewModel.threads */ }
.task { await viewModel.load() }
}
}
What changes
- State, validation, and async work move to the view model. The view just binds.
NotificationCenterbroadcasts disappear — parent views observe child view model state directly.- Each view model is unit-testable against a protocol-based fake service, no UI required.
@Observable(Observation framework) instead ofObservableObject— matches Apple's current direction and avoids the@Publishedboilerplate.
Migration order
Notifications first — it has the clearest service boundary and the mark-as-read fix from #33 already touched these paths. Then one feature at a time: repositories, issues, pull requests. Each PR is self-contained. Unmigrated features keep working as-is.
Offer
Happy to ship PR 1 (Notifications view model extraction) whenever you're ready. No rush — just wanted to lay out what the concrete shape looks like so you can evaluate it.