An Android budgeting app built on event-driven sync architecture β handling real-world network failures, multi-user conflicts, and distributed data consistency using patterns found in production backend systems.
Most Android portfolio apps demonstrate UI and API calls. This one solves a harder problem: what happens when users on different devices edit shared data, sometimes without internet, and changes need to converge correctly?
This app implements an offline-first collaborative budgeting system using an Outbox/Inbox sync model, server-side versioning, and idempotent event processing β the same class of patterns used in distributed production systems. The goal was to go beyond standard CRUD and engage seriously with data consistency and sync reliability at the application layer.
Checkout a demo vide of Budgetify Android App
- Core Features
- Architecture
- Design Decisions & Trade-offs
- Sync System Design
- Tech Stack
- Testing
- Project Structure
- Setup & Installation
- Limitations & Roadmap
| Area | What It Does |
|---|---|
| Budgeting | Create budgets, categories, and expenses with full CRUD support |
| Offline-First | All actions work without internet; changes sync automatically when network returns |
| Collaboration | Share budgets with up to 10 participants via invite link |
| Conflict Resolution | Server-side versioning prevents stale overwrites across devices |
| Sync Reliability | Idempotent events with retry guarantee no data loss |
| Modes | Offline-only (no login) or collaborative (Firebase Auth) |
The app follows Clean Architecture with MVVM, organized into independent Gradle modules with a strict unidirectional dependency flow: app β domain β data.
app (UI Layer)
β
domain (Business Logic)
βββ repository
βββ auth-repo
βββ sync β all sync complexity lives here
β
data (Implementation)
βββ budget-database β Room
βββ budget-api β Retrofit
βββ auth β Firebase
βββ media-store β local file storage
When a user adds an expense in a shared budget:
graph LR
A[UI] -->|Input| B[ViewModel]
B -->|Domain Model| C[Repository]
subgraph Atomic Transaction
C --> D[(Room DB)]
C --> E[(Outbox Table)]
end
E -->|Triggers| F[Sync Worker]
F -->|API Request| G[Server]
G -.->|Acknowledgment| F
F -.->|Delete Record| E
D -.->|Reactive Flow| B
B -.->|ViewState| A
The local DB and Outbox are written atomically in the same transaction β ensuring a change is never persisted without also being queued for sync.
graph LR
Server((Server)) -- Pull/Push --> Sync[Sync Worker]
Sync -- Raw Storage --> Inbox[(Inbox Buffer)]
Inbox -- Process Sequentially --> Room[(Room DB)]
Room -- Kotlin Flow --> Repo[Repository]
Repo --> VM[ViewModel]
VM --> UI[UI]
Incoming events are buffered in an Inbox table before being applied, preserving ordering and enabling safe retry if processing fails mid-way.
The server is the authoritative source of truth β only server-accepted events become globally consistent state. The local database acts as an optimistic operational store: changes appear instantly in the UI, then are validated and confirmed asynchronously. This is a server-authoritative, offline-first model.
This is the most important section of the project. Every major architectural choice involved a deliberate trade-off.
User actions are stored as immutable events (e.g., expense.add, category.delete) rather than sent directly as API mutations. Events can be replayed safely, retried idempotently, and processed in order. The cost is significantly higher system complexity β but the payoff is reliable sync under any network condition.
Each entity row carries a server-assigned version number. Update and delete events must include the current version. If there's a mismatch, the server either returns the latest record or rejects the event, and the client reconciles. This is simpler than merge-based strategies (no CRDT complexity), but requires explicit client-side reconciliation on conflict.
WorkManager alone isn't suited for long continuous tasks like a full snapshot download. Coroutines alone offer no reliability guarantees across process death. The solution uses WorkManager for scheduling, constraints, and retries β and a foreground service for heavy, long-running sync operations. Each handles what it's best at.
Budget data does not change with high frequency. WebSockets or Firebase Realtime Database would add infrastructure complexity for marginal benefit. Long polling is simpler, maintainable, and sufficient for the eventual-consistency model this app targets. The trade-off is a small delay in propagating remote changes.
When a user logs in or joins a shared budget, a full dataset snapshot is downloaded rather than a delta. This guarantees correct initial state without needing to track complex ordering across entity types. The trade-off is slower first-load for large datasets β addressed by making snapshot sync resumable via persisted progress tracking.
All changes are represented as immutable, idempotent events with a globally unique eventId. The server uses this ID to detect and deduplicate retried requests, returning a cached response instead of reprocessing.
// Create event (no version)
{
"eventId": "d0e9f472-0992-4841-a43a-537b9a89d9b6",
"eventType": "expense.add",
"budgetId": "64143031-e16c-4c97-859d-8997e94ed98e",
"recordId": "813667ca-8a40-4678-9602-f198d86eff1e",
"when": 1774718381798,
"note": "coffee",
"amount": "150.00",
"date": "2025-05-06"
}
// Update/Delete event (version required)
{
"eventId": "2bcf8502-49e4-4601-aa28-2c9da0d88bb3",
"eventType": "expense.delete",
"budgetId": "a8d7a529-99fe-4a93-9ed5-cd9db2b1349a",
"recordId": "d99a0b0d-b8ae-4d2d-94aa-a12399049697",
"version": 3,
"when": 1774718619903
}Outbox events are written atomically with the local data change in the same Room transaction. The sync worker reads pending events, batches them (size 25), sends to server, and deletes confirmed events. Failed events remain in the Outbox and are retried automatically.
InboxEnqueueService β Inbox Table β InboxDequeueWorker β InboxCoordinator β Room DB
Long polling fetches events ordered by a monotonically increasing server sequence number. The client stores a per-budget cursor (last processed sequence) to fetch only new events. Events are staged in the Inbox buffer before being applied β preserving ordering guarantees and enabling recovery if the device dies mid-processing.
| Scenario | Resolution |
|---|---|
| Version match | Update applied normally |
| Version mismatch β server returns latest record | Client applies server state directly |
| Version mismatch β server rejects without data | Client fetches latest snapshot manually |
| Inbox event arrives | Applied without version check (server already resolved it) |
Sync is triggered by multiple conditions rather than a single polling loop:
- Event-driven: Outbox observer fires sync when new events are added
- Periodic: Inbox polling worker, snapshot recovery worker
- Lifecycle: App startup, user login, budget join, screen open
- Conditional: Network availability and battery constraints via WorkManager
- Outbox events are ordered by
eventTime (when)β client-side causality - Inbox events are ordered by server
sequence numberβ global causality
This ensures deterministic state reconstruction across all participating devices.
| Layer | Technology | Reason |
|---|---|---|
| UI | Jetpack Compose | Declarative, reactive, pairs naturally with StateFlow |
| State Management | ViewModel + StateFlow | Lifecycle-aware; UDF enforced by design |
| Local DB | Room | Compile-time safety, Flow integration, PagingSource support |
| Networking | Retrofit + Kotlinx Serialization | Type-safe APIs; serialization integrated with Kotlin |
| Background Sync | WorkManager + Foreground Service | Guaranteed execution, constraint handling, retry logic |
| Async | Kotlin Coroutines + Flow | Cold streams from DB, hot StateFlow for UI |
| DI | Hilt | Module-scoped bindings, testability |
| Auth | Firebase Authentication | Handles collaborative mode login |
| Pagination | Paging 3 | Efficient large dataset rendering with Room |
| Image Loading | Coil | Kotlin-first, Compose compatible |
| CI | GitHub Actions | Automated build and test pipeline |
Key choices explained:
- WorkManager over coroutines-only β coroutines don't survive process death; WorkManager guarantees execution
- Flow over LiveData β better coroutine integration, no lifecycle import in domain layer, native Compose support
- Room over raw SQLite β type converters, relationship support, and compile-time query validation
Room migration tests β validates schema evolution correctness, critical given the architectural shift from a simple CRUD schema to the offline-first Outbox/Inbox model. Breaking a migration breaks user data.
API layer unit tests β validates request/response handling and Kotlinx Serialization correctness for all event types.
Tools: JUnit, MockK
Given the complexity of the sync system, manual testing covers:
- Create/update/delete in airplane mode β restore network β verify sync
- Duplicate event submission β verify idempotency (no duplicates in DB)
- Version conflict scenarios β verify client reconciliation
- Partial snapshot download interrupted β verify resumable recovery
- Concurrent Outbox events β verify ordering and no data loss
The modular architecture allows each layer to be tested independently. Domain layer has no Android dependencies. Data sources are abstracted behind interfaces, enabling full mocking without instrumentation.
ViewModel state logic, repository coordination, and sync orchestration currently lack automated coverage. These are the highest-priority areas for the testing roadmap (see Limitations & Roadmap below).
app/ β UI layer (Compose screens, ViewModels, Navigation)
domain/
βββ repository/ β data coordination, entity β domain transformation
βββ auth-repo/ β user session, login state
βββ sync/ β Outbox/Inbox workers, sync orchestration
data/
βββ budget-database/ β Room DB, DAOs, migrations, Outbox/Inbox tables
βββ budget-api/ β Retrofit services, event serialization
βββ auth/ β Firebase Auth integration
βββ media-store/ β local file handling (profile images)
Dependency rule: app depends on domain; domain depends on data; data has no knowledge of domain or app. This is strictly enforced β data modules contain zero business logic.
- Android Studio Narwhal Feature Drop 2025.1.4+ (Canary 4)
- Java 17+
- Gradle 8.11.1 / AGP 8.10.1
git clone https://github.com/rahulstech/budget-app-android.git
cd budget-app-androidOpen in Android Studio and allow Gradle sync to complete.
Firebase (required for collaborative mode)
Add google-services.json to app/ and enable Email/Password authentication in Firebase Console. Without this, the app runs in offline-only mode.
Backend API
Update release.properties:
BUDGET_API_BASE_URL=your_base_url_here
BUDGET_API_KEY=your_api_key_heredebug.properties is pre-configured for localhost β leave it unchanged if running the backend locally. Without a backend, the app works fully in offline mode.
Offline flow
- Enable airplane mode
- Create budgets, categories, and expenses
- Disable airplane mode
- Observe automatic background sync
Collaborative flow
- Log in with Firebase Auth on two sessions
- Perform conflicting edits on shared budget
- Observe conflict resolution and eventual consistency
- No real-time sync β long polling introduces a small propagation delay
- Full snapshot on first load β initial sync is slow for large datasets
- No conflict resolution UI β conflicts are resolved silently server-side; users don't see them
- Limited automated test coverage β ViewModel, repository, and sync logic not yet covered by unit tests
- No delta sync β every initial load downloads the full dataset regardless of what's changed
| Priority | Improvement |
|---|---|
| High | Unit tests for ViewModel, repository, and sync orchestration |
| High | Flow testing with Turbine; fake repository implementations |
| Medium | Incremental (delta) sync β fetch only changes since last cursor |
| Medium | Conflict resolution UI β surface conflicts to users with resolution options |
| Medium | Improved sync progress visibility in UI |
| Low | Dark mode and visual polish |
| Long-term | Explore CRDTs for automatic conflict resolution without strict version matching |
Open to Android Developer roles and backend/system design discussions.
- GitHub: rahulstech
- LinkedIn: Rahul Bagchi
- Email: rahulstech18@gmail.com
If this project is interesting to you β whether as a recruiter, collaborator, or fellow engineer β feel free to reach out.
