(04) Systems / Case study
yourFinance
The database guarantees the balances.
yourFinance is a multi-user personal finance app: a Rust API, PostgreSQL and one client for iOS, Android, web and desktop. The code is private; this page shows how it is built and why.
- Started
- July 2025
- Version
- 1.2.0, October 2026
- Team
- Two developers
- Code
- Private
Rust · Axum · SQLx · PostgreSQL 18 · Expo · React Native · Tauri · Docker
The problem
Personal finance data is only useful if the balances can be trusted. Several people use the app from several devices, and a bug in a client or in a handler must not be able to leave an account with the wrong figure.
It also has to handle more than one currency, take in the full history of another app (Money Manager) and show statistics that are never stale.
Constraints
- The API never writes a balance: every change is a movement, a balance adjustment included.
- Every personal row belongs to one user, and PostgreSQL repeats the rule with row-level security on each pooled connection.
- One codebase for iOS, Android and web; the desktop app is the web export inside a Tauri shell.
- A change to the schema or to the API has to break the build, not the running app: SQL is checked at compile time and the client types are generated from the OpenAPI document.
Architecture
Three tiers: the client only presents, the API authenticates, validates and aggregates, the database is the source of truth. The API is a Cargo workspace of layered crates: each depends only on the ones below it, and CI checks that the domain stays free of I/O.
yourf-api
HTTP: thin routes, authentication, error mapping, OpenAPI.
yourf-worker
Background jobs: push notifications, alert digest, recurring movements, balance reconciliation.
yourf-runtime
What the two binaries share: configuration, telemetry, lifecycle.
yourf-db
SQLx repositories, row-level security scope, writes in one transaction.
yourf-import
Readers of other apps' files into a neutral format, with no database.
yourf-domain
Models, contracts and business rules. No I/O.
Two binaries, one image: API and worker deploy and scale separately.
One request, end to end
Creating a movement:
- 01Client
The form validates the draft and sends it with the bearer token.
- 02API
It authenticates the token, loads the user, validates the body and inserts the movement with its account and category splits in one transaction.
- 03Database
Deferred triggers check that the splits add up and that accounts, categories and tags belong to the user, then update the balances. A violation rolls everything back and comes out as a 400 with a stable rule code.
- 04Client
On success the lists, the totals and the dashboard reload.
Decisions and trade-offs
- 01
Integrity lives in the database
Balances, data ownership and category rules are enforced by triggers. It means writing logic in SQL instead of Rust; in return no client and no handler can corrupt the data.
- 02
Checkable balances, not a full double-entry ledger
Every effect on a balance is a signed posting with a single definition, used both by the trigger that writes the balances and by the job that reconciles them. A full ledger would be worth the rewrite only if a feature needed a balance sheet.
- 03
A transfer is one movement with two amounts
What left the source account and what reached the destination. The difference covers a currency exchange as well as a fee, which therefore does not show up as an expense in the statistics.
- 04
No job queue
The worker claims the due rows with FOR UPDATE SKIP LOCKED and the push outbox is woken by LISTEN/NOTIFY. Every sweep is idempotent: running it twice, or on several replicas, gives the same result.
- 05
Statistics computed on every request
Dashboard and statistics aggregate the movements in SQL each time: no pre-computed aggregates and no cache, so the figures are never stale. The price is that the queries have to be fast on their own.
Measured
Profiling the analytics queries showed two of them growing with the whole history instead of the selected period. With the generic plan of a prepared statement, the planner chose a nested loop that re-ran a join once per movement. Indexes were not the problem: the fix was rewriting the view so that each branch starts from the movement.
| Query | Before | After |
|---|---|---|
| Daily flows (statistics trend)daily_flows | 64.5 ms | 1.9 ms |
| Essential expenses (dashboard summary)essential_expenses | 48.0 ms | 1.5 ms |
The app



What comes next
- An expiry date on accounts, with a notice before it arrives.
- Amortisation: a cost spread over several months in the statistics, while the balance stays cash-based.
- An assistant that answers questions about statistics and accounts through read-only tools: the model picks the tool, the backend computes every number.
Code and demo
The repository is private. On request I walk through the code and the running app.