Layered MVVM Keeps SwiftUI Apps Scalable
Use a 'full layer cake' MVVM with Models, Repositories, Services, ViewModels, and Views to separate concerns in SwiftUI apps, enabling testability, maintainability, and growth without monolithic views.
Full Layer Cake MVVM Prevents Spaghetti Code
SwiftUI tempts you to cram networking, persistence, and business logic into views, creating fragile monoliths by the third feature. Counter this with a five-layer MVVM architecture that enforces clear responsibilities and scales reliably:
- Models: Pure data shapes like
@Model class Taskwith fields (id, title, priority enum, dueDate, subTasks via@Relationship). Keep models ignorant of UI details like colors. - Repositories: Centralize persistence via protocols (e.g.,
TaskRepositoryProtocolwithfetchAll(),fetchTodayTasks(now:),add(_:),delete(_:),toggleComplete(_:)). Implement with SwiftData'sModelContext, usingFetchDescriptorfor sorted queries and predicates for date filtering (e.g., today's tasks via calendar start/end of day). - Services: Handle cross-screen logic like
DateManagerforisToday(_:), time-based greetings (hour <12: "Good Morning"), and formatted due dates ("Today at 3PM", "Tomorrow"). - ViewModels:
@Observableclasses own screen state (e.g.,TodayViewModelwithtasks: [Task],errorMessage, computedcompletedCount). Inject dependencies, call repos/services on user actions (e.g.,toggleCompletesaves via repo then reloads), and expose UI-ready data. - Views: Render only, forwarding actions (e.g.,
TodayViewusesForEachonviewModel.tasks, buttons callviewModel.toggleComplete(task)).
Data flows unidirectionally: View → ViewModel → Repo/Service → Model → observable state update → View re-render. This keeps views lean (no @Query, no business rules in onAppear).
Dependency Injection Wires It Cleanly
Avoid hard-coded dependencies by injecting at the app root. In @main TodoMVVMApp, create ModelContainer for Task.self. In RootView, build repo = TaskRepository(context: modelContext), then vm = TodayViewModel(repository: repo), pass to TodayView(viewModel: vm). This enables swapping real repos for fakes in tests.
For a todo app with Today/All Tasks/Search/Settings/sub-tasks, this structure handles SwiftData persistence, filters, @AppStorage, and UI polish without views owning storage.
Testability and Pitfalls Unlocked
Protocols make ViewModels unit-testable: FakeTaskRepository mocks tasks: [Task], overrides methods to append/remove/toggle without SwiftData. Test load() sets tasks, toggleComplete updates state.
Avoid these traps:
- Views querying SwiftData directly (leads to duplicated fetch logic).
- God ViewModels handling persistence.
- Instantiating repos inside ViewModels (blocks testing).
Result: Boring, trustworthy code where refactors aren't scary, features add cleanly, and months later you grok it instantly. Sample: TodoMVVM GitHub repo.