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 Task with fields (id, title, priority enum, dueDate, subTasks via @Relationship). Keep models ignorant of UI details like colors.
  • Repositories: Centralize persistence via protocols (e.g., TaskRepositoryProtocol with fetchAll(), fetchTodayTasks(now:), add(_:), delete(_:), toggleComplete(_:)). Implement with SwiftData's ModelContext, using FetchDescriptor for sorted queries and predicates for date filtering (e.g., today's tasks via calendar start/end of day).
  • Services: Handle cross-screen logic like DateManager for isToday(_:), time-based greetings (hour <12: "Good Morning"), and formatted due dates ("Today at 3PM", "Tomorrow").
  • ViewModels: @Observable classes own screen state (e.g., TodayViewModel with tasks: [Task], errorMessage, computed completedCount). Inject dependencies, call repos/services on user actions (e.g., toggleComplete saves via repo then reloads), and expose UI-ready data.
  • Views: Render only, forwarding actions (e.g., TodayView uses ForEach on viewModel.tasks, buttons call viewModel.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.