Skip to content
All projects

Independent product · Mobile & Web

Kairo

Offline-first personal finance app built with Expo, React Native and TypeScript. All financial data lives on the device in SQLite, which keeps it fast, private and independent of a network connection.

Period
2026 · In development
Role
Architecture, Design System and implementation
  1. UIExpo Router screens compose Design System components and consume hooks, with no SQL and no business rules.
  2. HooksCall services and cache results with React Query, exposing loading and error state.
  3. ServicesThe only place with business rules; plain TypeScript, unit tested.
  4. RepositoriesData access for one entity, mapping rows to typed domain entities; depend only on a DatabaseAdapter.
  5. SQLiteSingle source of truth for financial data, with versioned migrations.
The Design System consumes only theme tokens and receives data exclusively through props, so it could be extracted as a standalone package.

Context

A finance app has to be trustworthy before anything else: balances must always add up, data must survive without a connection, and the codebase must stay maintainable as cards, installments, recurring transactions and sync arrive. Its architecture was planned around those constraints and documented before implementation.

Architecture

Business rules live in services, data access in repositories and React only orchestrates. Each layer has a single responsibility and a list of things it is not allowed to do.

SQLite → Repositories → Services → Hooks → UI

Key decisions

  • Money as integer cents

    Floating-point arithmetic produces rounding errors that are unacceptable in a finance app (0.1 + 0.2 ≠ 0.3).

    Decision: Every amount is stored as an INTEGER in cents and only converted for display in the formatting layer.

  • Derived balances

    A stored balance column drifts out of sync when transactions are edited, deleted or imported.

    Decision: Accounts store only their initial balance; the current balance is always computed from transactions and cached by React Query, invalidated on mutations.

  • The database as a local server

    Duplicating database data in client stores creates two sources of truth.

    Decision: React Query owns everything that comes from SQLite; Zustand holds UI state only. Treating SQLite as a "local server" keeps future sync close to transparent.

  • Ready for sync, without building it

    Auto-increment IDs collide across devices, and hard deletes break history.

    Decision: Client-generated UUID v4 IDs, ISO-8601 UTC dates and soft delete with audit timestamps on every table.

  • Adapters instead of dropping features

    MMKV, used for preferences on native, does not exist on the web.

    Decision: A PreferencesStorage interface with MMKV and localStorage implementations, resolved per platform (.ts / .web.ts).

  • Versioned migrations

    Ad-hoc "CREATE TABLE IF NOT EXISTS" calls scattered across the code make schema changes unpredictable.

    Decision: Sequential migrations tracked with PRAGMA user_version and executed inside a transaction at startup.

Quality

  • TypeScript strict mode, with Zod validation at the edges (forms and backup import).
  • A single typed error hierarchy (AppError → DomainError, ValidationError, NotFoundError, DatabaseError) mapped by code in the UI.
  • Jest and Testing Library tests for services, the migration and seed runners, utilities and Design System components.

Status

Implemented

  • Dashboard
  • Accounts
  • Categories
  • Income & expense tracking
  • Transfers
  • Cash flow visualization

Planned

  • Budgets
  • Credit cards & installments
  • Recurring transactions
  • Data export
  • Cloud backup & multi-device sync

Stack

  • Expo
  • React Native
  • Expo Router
  • TypeScript
  • SQLite
  • React Query
  • Zustand
  • Zod
  • NativeWind
  • Jest
Next case studyBatata com Granulado