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.