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 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.

Summarized by x-ai/grok-4.1-fast via openrouter

7799 input / 1709 output tokens in 15718ms

© 2026 Edge