LOG//ENTRY
SwiftUI + SwiftData Architecture: Repositories, MVVM and Tests
A practical SwiftUI and SwiftData architecture from a real app: repositories that own the database, @Observable view models, computed balances and fast tests.
By Siva Sundar · · 4 min read
SwiftData makes it easy to put @Query straight into a SwiftUI view and start shipping. For a small app that's fine. But once screens start computing balances, validating input and sharing data, logic spreads across views and becomes hard to test. These are the five decisions I made in MoneyLog, my offline expense tracker, to keep it simple to change.
The folder structure
MoneyLog/
├── App/ Launch: open the database, seed, top-level view
├── Core/
│ ├── Models/ Transactions, accounts, categories, budgets, goals
│ ├── Money/ Amounts and currency formatting
│ ├── Persistence/ Database, sample data, erase-and-reset
│ ├── Repositories/ The only code that reads and writes the database
│ └── Services/ Pure calculations: balances, budget pace, recurrence
├── DesignSystem/ Tokens (colour, type, spacing, motion) and components
└── Features/ One folder per screen: Today, Activity, Insights, Plan…The rule of thumb: Core doesn't know SwiftUI exists, Features doesn't know SwiftData exists, and DesignSystem knows neither.
1. Only repositories touch SwiftData
Screens never call ModelContext directly. They ask a repository for what they need. Each repository is a protocol plus a SwiftData implementation:
@MainActor
protocol TransactionRepository: AnyObject {
func transactions(matching query: TransactionQuery) throws -> [TransactionRecord]
func ledgerLines(in interval: DateInterval?) throws -> [LedgerLine]
@discardableResult func create(_ draft: TransactionDraft) throws -> TransactionRecord
func update(_ transaction: TransactionRecord, with draft: TransactionDraft) throws
func delete(_ transaction: TransactionRecord) throws
}This gives two concrete benefits. First, the rules about valid data live in one place: create validates the draft, checks that the account exists and that the category matches the transaction type, then saves or rolls back. Second, tests can run the real repository against an in-memory store, or swap in a fake.
Queries are plain values too. A TransactionQuery describes a date range, types, categories, accounts and search text. The repository decides which filters the store can do with a #Predicate and which are easier in Swift after the fetch.
2. Balances are calculated, never stored
There's no balance column anywhere in MoneyLog. An account's balance is its opening amount plus every transaction since. A stored balance is a number that can go wrong silently: a delete that forgets to update it, or a migration that misses it. A calculated one can't.
To keep that maths testable, repositories hand calculators a stripped-down LedgerLine value rather than the SwiftData model:
struct LedgerLine: Equatable, Sendable {
let amountMinor: Int64
let type: TransactionType
let date: Date
let accountID: UUID?
let destinationAccountID: UUID?
let categoryID: UUID?
}
enum BalanceCalculator {
static func cashFlow(of lines: [LedgerLine]) -> CashFlowSummary {
lines.reduce(into: CashFlowSummary()) { summary, line in
switch line.type {
case .income: summary.incomeMinor += line.amountMinor
case .expense: summary.expenseMinor += line.amountMinor
case .transfer: break // moving money isn't earning or spending it
}
}
}
}BalanceCalculator is a caseless enum of pure functions: values in, values out. Testing it needs no database, no simulator and no async setup. Recalculating on every load sounds wasteful, but with a local store and a personal-sized dataset it takes a fraction of a millisecond.
3. One @Observable view model per screen
Each screen has a view model that asks repositories for data, does the arithmetic and exposes plain values. With the Observation framework that's just a class marked @Observable. No @Published boilerplate:
@MainActor
@Observable
final class TodayViewModel {
private(set) var snapshot = DashboardSnapshot()
private(set) var isLoaded = false
var errorMessage: String?
func load(now: Date = .now) {
do {
let month = periods.month(containing: now)
let lines = try container.transactions.ledgerLines(in: month)
let cashFlow = BalanceCalculator.cashFlow(of: lines)
// …build the snapshot from plain values
} catch {
errorMessage = error.localizedDescription
}
}
}load(now:) takes the current date as a parameter, which makes date-dependent logic testable. One example: the dashboard compares this month's spending with the same elapsed stretch of last month, because comparing a half-finished month to a whole one would always look good. The view itself reads like a layout file.
4. A router and a data-version counter
Screens shouldn't need to talk to each other. A small @Observable AppRouter owns the selected tab and which sheets are open, so anything can say “open the add sheet”. It also holds a counter:
@MainActor
@Observable
final class AppRouter {
var selectedTab: AppTab = .today
var isPresentingAdd = false
private(set) var dataVersion = 0
func dataDidChange() { dataVersion += 1 }
}After any save or delete, the counter goes up and every screen watching it reloads. It's deliberately blunt: every screen reloads even if the change didn't affect it. But with a local database that costs almost nothing, and it's impossible to forget to refresh a screen.
5. Every visual value comes from the design system
Colours, fonts, spacing, corner radii, animation timings and haptics all live in DesignSystem/Tokens. Views never invent a colour or a padding value. Restyling the app means editing Palette.swift, not hunting through forty views.
How this pays off in tests
- Calculators (balances, budget pace, recurrence) are tested as pure functions.
- Repositories are tested against a throwaway in-memory SwiftData container, so tests never touch real data.
- View models can be driven with a fixed
nowdate. - One UI test launches the app and checks the first screen appears, which is enough to catch a launch crash.
When this is overkill
For a two-screen app, @Query in the view is the right call. The structure above earns its keep once you have rules about valid data, numbers that must be right, and more than one screen showing the same data in different ways. That point arrives sooner than you'd expect in almost any real product.