Last Updated: 2025-10-28 Status: ✅ Phase 2.5 COMPLETE | Phase 3 IN PROGRESS Tests: 302 passing (44 DAO tests, 24 entity serialization tests)
FairShare is an offline-first group expense sharing app built with Flutter, featuring real-time Firebase sync, clean architecture with use cases, and an event-driven reactive system.
- Flutter - Cross-platform mobile framework
- Riverpod - State management with code generation
- Drift - Type-safe SQLite ORM for offline storage
- Firebase Auth - Google Sign-In authentication
- Firestore - Cloud database with real-time sync
- result_dart - Result type for error handling
- freezed - Immutable data classes
Pure business logic - no infrastructure dependencies
- Entities: Immutable data models (
ExpenseEntity,GroupEntity, etc.) with Freezed serialization - Repository Interfaces: Abstract contracts (throw exceptions, no
Result<T>) - Use Case Interfaces: 11 interfaces for complete abstraction (testable in isolation)
- Use Case Base Class: Template method pattern with validation + execution separation
// Base Use Case (Template Method Pattern)
abstract class UseCase<Input, Output> with LoggerMixin {
Future<Result<Output, Exception>> call(Input input) async {
try {
validate(input); // Template method: override to validate
final result = await execute(input); // Template method: override to execute
return Success(result);
} catch (e, stack) {
log.e('Use case failed: $e', e, stack);
return Failure(e as Exception);
}
}
void validate(Input input) {} // Optional override
Future<Output> execute(Input input); // Required override
}
// Concrete Implementation
class CreateExpenseUseCase extends UseCase<ExpenseEntity, ExpenseEntity> {
final IExpenseRepository _repository; // ✅ Interface, not concrete
@override
void validate(ExpenseEntity input) {
if (input.amount <= 0) throw Exception('Amount must be > 0');
if (input.title.trim().isEmpty) throw Exception('Title required');
}
@override
Future<ExpenseEntity> execute(ExpenseEntity input) async {
return await _repository.createExpense(input);
}
}
// Provider exposes interface, not concrete type
@riverpod
ICreateExpenseUseCase createExpenseUseCase(Ref ref) {
return CreateExpenseUseCase(ref.watch(expenseRepositoryProvider));
}Implemented Use Cases (13 total):
| Category | Use Cases | Interfaces |
|---|---|---|
| Expenses | Create, Update, Delete, Get, GetByGroup | ICreateExpenseUseCase, IUpdateExpenseUseCase, etc. (5 interfaces) |
| Groups | Create, Update, Delete, AddMember, RemoveMember, JoinByCode | ICreateGroupUseCase, IUpdateGroupUseCase, etc. (6 interfaces) |
| Balances | CalculateGroupBalances | ICalculateGroupBalancesUseCase (1 interface) |
Key Improvements:
- ✅ All use cases implement interfaces (complete dependency inversion)
- ✅ Template method pattern with validation
- ✅ Logging via
LoggerMixin - ✅ Consistent error handling with
Result<T> - ✅ Testable in isolation (13 test files with mocked repositories)
Infrastructure implementations
SyncedExpenseRepository- ImplementsExpenseRepositorySyncedGroupRepository- ImplementsGroupRepository
Architecture (Phase 2.5):
- Repositories depend on DAO interfaces, not concrete types
- Accepts
AppDatabasefor transactions only (not for data access) - All data operations use injected interface implementations
- Complete test isolation: Repositories testable with mocked DAOs
Responsibilities:
- Atomic transactions (DB write + Queue entry)
- Fire domain events after successful operations
- Throw exceptions on error (Use Cases wrap in
Result<T>) - Delegate data operations to injected DAOs
// Repository Pattern (Dependency Injected)
class SyncedExpenseRepository implements ExpenseRepository {
final AppDatabase _database; // ✅ For transactions only
final IExpensesDao _expensesDao; // ✅ Interface, injected
final IEventBroker _eventBroker; // ... other injected interfaces
@override
Future<ExpenseEntity> createExpense(ExpenseEntity expense) async {
await _database.transaction<void>(() async {
await _expensesDao.insertExpense(expense); // ✅ Use injected DAO
await _syncDao.enqueueOperation(/* ... */);
});
_eventBroker.fire(ExpenseCreated(expense)); // ✅ Use injected event broker
return expense;
}
}Key Improvements (Phase 2.5):
- ✅ Repositories no longer depend on concrete DAO implementations
- ✅ All data access is through interfaces (IExpensesDao, ISyncDao, etc.)
- ✅ EventBroker is interface-based (IEventBroker), not singleton
- ✅ Testable: Can inject mock DAOs and EventBroker
- ✅ Clean separation: AppDatabase only used for transaction coordination
5 DAO Interfaces for complete abstraction:
| Interface | Implementation | CRUD | Streams | Special Methods |
|---|---|---|---|---|
IExpensesDao |
ExpensesDao |
Create, Read, Update, Delete | watchByGroup(), watchAll() |
upsertFromSync(), softDelete() |
IGroupsDao |
GroupsDao |
Create, Read, Update, Delete | watchAll() |
upsertFromSync(), member management |
IExpenseSharesDao |
ExpenseSharesDao |
Create, Read, Delete | - | getSharesByGroup() |
ISyncDao |
SyncDao |
- | - | enqueueOperation(), dequeueOperations(), markFailed() |
IUserDao |
UserDao |
Create, Read, Update | - | upsert() |
Key Features:
- ✅ All DAOs implement interfaces (testable with mocks)
- ✅ 44 comprehensive tests covering CRUD, soft delete, streams, sync upsert
- ✅ Special
upsertFromSync()methods for conflict resolution (Last Write Wins) - ✅ Stream support for reactive queries
- ✅ User-scoped operations for multi-user isolation
// DAO Interface
abstract class IExpensesDao {
Future<ExpenseEntity?> getExpenseById(String id);
Stream<List<ExpenseEntity>> watchExpensesByGroup(String groupId);
Future<void> insertExpense(ExpenseEntity expense);
Future<void> updateExpense(ExpenseEntity expense);
Future<void> softDeleteExpense(String id);
// Sync method - accepts EventBroker as parameter (Drift compatible)
Future<void> upsertExpenseFromSync(
ExpenseEntity expense,
IEventBroker eventBroker,
);
}
// DAO Implementation with Conflict Resolution
class ExpensesDao implements IExpensesDao {
Future<void> upsertExpenseFromSync(
ExpenseEntity expense,
IEventBroker eventBroker,
) async {
final existing = await getExpenseById(expense.id);
if (existing == null) {
// ✅ New expense - insert
await into(expenses).insert(expenseToDb(expense));
eventBroker.fire(ExpenseCreated(expense));
} else if (expense.updatedAt.isAfter(existing.updatedAt)) {
// ✅ Remote is newer (Last Write Wins) - update
await update(expenses).write(expenseToDb(expense));
eventBroker.fire(ExpenseUpdated(expense));
}
// ✅ If local is newer, do nothing (no event)
}
}
// Provider exposes interface, not concrete type
@riverpod
IExpensesDao expensesDaoProvider(Ref ref) {
return ref.watch(databaseProvider).expensesDao;
}Why Interfaces for DAOs?
- Repositories can be tested without real database (mock DAOs)
- Enables complete test isolation across all layers
- Consistent with dependency inversion principle
UI and state management
- AuthScreen - Google Sign-In
- CreateExpenseScreen - Expense creation form
- CreateGroupScreen - Group creation form
- JoinGroupScreen - Join by 6-digit code
- HomeScreen - Main tabbed interface
- Command Pattern: UI calls Use Cases directly via
ref.read() - Query Pattern: Stream providers watch repository streams
// UI calls Use Case directly (no Notifier!)
final useCase = ref.read(createExpenseUseCaseProvider);
final result = await useCase(expense);
result.fold(
(success) => showSuccess('Expense created!'),
(error) => showError(error.toString()),
);
// Stream provider for reactive queries
@riverpod
Stream<List<ExpenseEntity>> expensesByGroup(Ref ref, String groupId) {
return ref.watch(expenseRepositoryProvider).watchExpensesByGroup(groupId);
}Shared infrastructure
Purpose: Decoupled reactive updates across the app
Key Feature: Refactored from singleton to Riverpod-managed (Phase 2.5)
// Event Broker Interface
abstract class IEventBroker {
Stream<AppEvent> get stream;
void fire(AppEvent event);
Stream<T> on<T extends AppEvent>();
void dispose();
bool get isClosed;
bool get hasListeners;
}
// Event Broker Implementation (no singleton!)
class EventBroker implements IEventBroker {
final _controller = StreamController<AppEvent>.broadcast();
@override
void fire(AppEvent event) => _controller.add(event);
@override
Stream<AppEvent> get stream => _controller.stream;
@override
Stream<T> on<T extends AppEvent>() => stream.whereType<T>();
@override
void dispose() => _controller.close();
@override
bool get isClosed => _controller.isClosed;
@override
bool get hasListeners => _controller.hasListener;
}
// Riverpod Provider (lifecycle-managed)
@Riverpod(keepAlive: true)
IEventBroker eventBroker(Ref ref) {
final broker = EventBroker();
ref.onDispose(broker.dispose);
return broker;
}
// Event Types
sealed class AppEvent {}
// Expense Events
class ExpenseCreated extends AppEvent { final ExpenseEntity expense; }
class ExpenseUpdated extends AppEvent { final ExpenseEntity expense; }
class ExpenseDeleted extends AppEvent { final String id; }
// Group Events
class GroupCreated extends AppEvent { final GroupEntity group; }
class MemberAdded extends AppEvent { final String groupId, userId; }Events fire for BOTH:
- Local operations (via repositories)
- Remote sync operations (via DAOs)
Improvements (Phase 2.5):
- ✅ EventBroker now implements
IEventBrokerinterface - ✅ Removed singleton anti-pattern
- ✅ Riverpod manages lifecycle and cleanup
- ✅ Testable: Can provide mock
IEventBrokerin tests - ✅ No global state pollution
SyncService - Orchestrates sync lifecycle
- Starts/stops listeners based on app lifecycle
- Triggers upload queue on domain events
- Monitors connectivity
- Provides manual sync
UploadQueueService - Processes pending local changes
- Reads from
sync_queuetable - Uploads to Firestore
- Retries on failure
- Removes from queue on success
RealtimeSyncService - Downloads remote changes
- Hybrid listener strategy:
- Tier 1: Single listener for all user's groups (metadata)
- Tier 2: Dedicated listener for active group (full real-time)
- Tier 3: On-demand fetch for inactive groups with activity
- Calls DAO
upsertFromSync()methods - Passes
EventBrokerto DAOs
SQLite via Drift ORM
Tables:
users- User profilesgroups- Both personal and shared groupsgroup_members- Many-to-many memberships (single source of truth)expenses- All expenses (unified model)expense_shares- Custom splitsgroup_balances- Pre-calculated balances (ready, not yet used)sync_queue- Pending upload operations
Key Features:
- Foreign key constraints with CASCADE delete
- Soft deletes via
deletedAttimestamp - User-scoped data (all tables filtered by
ownerId) - Atomic transactions
1. UI calls CreateExpenseUseCase
↓
2. Use Case validates input
↓ (throws if invalid)
3. Use Case calls Repository.createExpense()
↓
4. Repository atomic transaction:
- Insert to expenses table
- Add to sync_queue
↓
5. Repository fires ExpenseCreated event
↓
6. EventBroker broadcasts to all listeners
↓
7. UI updates instantly (Drift stream + events)
↓
8. (Background) UploadQueueService processes queue
↓
9. Expense uploaded to Firestore
↓
10. Other devices receive snapshot → step 11
1. Device B creates expense (follows write flow above)
↓
2. Firestore document created
↓
3. Device A: RealtimeSyncService listener fires
↓
4. Service calls DAO.upsertExpenseFromSync(expense, eventBroker)
↓
5. DAO compares timestamps (Last Write Wins)
↓
6. If remote newer:
- Update local DB
- Fire ExpenseCreated/Updated event
↓
7. Device A UI updates instantly
Key Insight: Same events fire for local AND remote changes!
Use Cases:
- Handle validation and error wrapping
- Return
Result<T>(Success or Failure) - Wrap repository calls in try-catch
Repositories:
- Focus on data operations only
- Throw exceptions directly
- No
Result<T>wrapping
Benefits:
- Single Responsibility Principle
- Clean interfaces
- Consistent error flow
Challenge: Need to test repositories without real database and with mocked DAOs
Solution: Inject DAO interfaces + EventBroker into repositories, use AppDatabase only for transactions
// Repository depends on interfaces, not concrete implementations
class SyncedExpenseRepository implements ExpenseRepository {
final AppDatabase _database; // ✅ For transactions only
final IExpensesDao _expensesDao; // ✅ Interface, injected
final ISyncDao _syncDao; // ✅ Interface, injected
final IEventBroker _eventBroker; // ✅ Interface, injected
SyncedExpenseRepository({
required AppDatabase database,
required IExpensesDao expensesDao, // Injected DAO interface
required ISyncDao syncDao,
required IEventBroker eventBroker, // Injected event interface
required this.ownerId,
});
}Benefits:
- Repositories testable with mock DAOs (no database required)
- EventBroker is mockable interface, not singleton
- Clean dependency flow: Repositories → DAO interfaces
- AppDatabase only used for transaction coordination
Testability Impact:
// Test: Repository can be tested without database
final mockExpensesDao = MockIExpensesDao();
final mockEventBroker = MockIEventBroker();
final repo = SyncedExpenseRepository(
database: mockDatabase,
expensesDao: mockExpensesDao, // ✅ Mock
syncDao: mockSyncDao,
eventBroker: mockEventBroker, // ✅ Mock
ownerId: 'user123',
);
// All operations use mocks, no real database neededChallenge: Drift DAOs can't have custom constructors; need EventBroker in sync methods
Solution: Pass IEventBroker interface as method parameter to sync methods
// DAO method signature (uses interface)
Future<void> upsertExpenseFromSync(
ExpenseEntity expense,
IEventBroker eventBroker, // ✅ Interface parameter
)Benefits:
- Maintains Drift compatibility
- Clean architecture (no singleton)
- EventBroker fully mockable in tests
- No global state pollution
Design:
- Marked with
isPersonal: trueflag - ID format:
userId(same as owner's user ID) - Metadata NOT synced to Firestore (privacy)
- Expenses ARE synced (cloud backup)
Sync Behavior:
Personal Group:
- groups/{userId} → NOT synced ❌
- groups/{userId}/expenses/{id} → SYNCED ✅
Shared Group:
- groups/{groupId} → SYNCED ✅
- groups/{groupId}/expenses/{id} → SYNCED ✅
Problem: 50 groups = 50 listeners = expensive 💰
Solution:
- 1 global listener for all groups (metadata only)
- 1 active listener for currently viewed group (full updates)
- On-demand fetch for inactive groups with new activity
Result: 2 listeners total regardless of group count
Current Status: 302 tests passing
| Layer | Tests | Coverage | Notes |
|---|---|---|---|
| Event System | 8 | - | EventBroker interface + implementation |
| Use Cases | 13 test files | Isolated | All use cases with mocked repositories |
| Repositories | 137 | Isolated | All use cases with mocked DAOs |
| DAOs | 44 | Isolated | CRUD, soft delete, streams, sync upsert, conflict resolution |
| Sync Services | 12 | Integration | Real Drift DB + mocked Firestore |
| Balance Services | 14 | Pure logic | Calculation algorithm, settlement optimization |
| Balance Providers | 10 | Riverpod | Event-driven updates, reactive streams |
| Entity Serialization | 24 | - | User, ExpenseShareEntity, GroupMemberEntity, schema |
| Integration | 2 | E2E | End-to-end flows |
| TOTAL | 302 | ~32% overall | Core logic significantly higher |
Key Achievements:
- ✅ All DAOs tested with interface mocks (44 tests)
- ✅ All use cases tested with mocked repositories (13 test suites)
- ✅ Complete test isolation across all layers
- ✅ Entity serialization verified (snake_case JSON field names)
- ✅ Schema validation tests included
Next:
- Phase 3: UI integration and testing
- Performance profiling under load
- E2E tests for critical user flows
lib/
├── core/
│ ├── events/ # Event system (interface-based)
│ │ ├── app_events.dart # Event types
│ │ ├── event_broker_interface.dart # ✅ IEventBroker interface
│ │ ├── event_broker.dart # Riverpod-managed implementation
│ │ └── event_providers.dart
│ ├── database/ # Drift database + DAO interfaces
│ │ ├── app_database.dart
│ │ └── interfaces/
│ │ └── dao_interfaces.dart # ✅ IExpensesDao, IGroupsDao, etc. (5 interfaces)
│ ├── sync/ # Sync services (interface-based)
│ │ ├── sync_service_interfaces.dart # ✅ ISyncService, IUploadQueueService, etc.
│ │ ├── sync_service.dart
│ │ ├── upload_queue_service.dart
│ │ └── realtime_sync_service.dart
│ └── domain/
│ └── use_case.dart # ✅ Base class with template method pattern
├── features/
│ ├── expenses/
│ │ ├── domain/
│ │ │ ├── entities/ # Freezed with JSON serialization
│ │ │ ├── repositories/ # ExpenseRepository interface
│ │ │ ├── use_case_interfaces.dart # ✅ ICreateExpenseUseCase, etc. (5 interfaces)
│ │ │ └── use_cases/ # 5 concrete implementations
│ │ ├── data/
│ │ │ └── repositories/ # SyncedExpenseRepository
│ │ └── presentation/
│ │ ├── providers/ # Riverpod providers (expose interfaces)
│ │ └── screens/ # UI screens
│ ├── groups/
│ │ ├── domain/
│ │ │ ├── entities/ # Freezed with JSON serialization
│ │ │ ├── repositories/ # GroupRepository interface
│ │ │ ├── use_case_interfaces.dart # ✅ ICreateGroupUseCase, etc. (6 interfaces)
│ │ │ └── use_cases/ # 6 concrete implementations
│ │ ├── data/
│ │ └── presentation/
│ ├── balances/
│ │ ├── domain/
│ │ │ └── use_cases/ # ICalculateGroupBalancesUseCase
│ │ └── presentation/
│ │ └── providers/ # Balance providers (event-driven)
│ └── auth/
│ ├── domain/
│ ├── data/
│ └── presentation/
└── shared/
├── routes/ # Go Router config
└── theme/ # Material 3 theme
Phase 2.5 Achievement: Complete Dependency Inversion
Presentation Layer
↓ depends on (ICreateExpenseUseCase, IGroupBalanceProvider, etc.)
Domain Layer
↓ depends on (IExpenseRepository, IEventBroker, etc.)
Data Layer
↓ depends on (IExpensesDao, ISyncDao, etc.)
Core Layer
↓ (no dependencies)
| Component Type | Count | Status |
|---|---|---|
| Repository Interfaces | 4 | ✅ Implemented |
| Use Case Interfaces | 11 | ✅ Implemented (13 total use cases) |
| DAO Interfaces | 5 | ✅ Implemented (44 tests) |
| Sync Service Interfaces | 3 | ✅ Implemented |
| Event Broker Interface | 1 | ✅ Implemented (singleton removed) |
| TOTAL | 24 | ✅ COMPLETE |
- ✅ Complete dependency inversion (24 interfaces)
- ✅ Clean Architecture with clear layer separation
- ✅ Single Responsibility Principle (one class per file)
- ✅ No cyclic dependencies
- ✅ Type-safe throughout
- ✅ Zero singleton anti-patterns
- ✅ 302 tests passing with complete isolation
- ✅ All DAOs testable with mocks (44 tests)
- ✅ All use cases testable with mocked repositories (13 test files)
- ✅ Entity serialization verified (24 tests)
- ✅ ~32% overall coverage, core logic significantly higher
- ✅ Instant UI updates (< 10ms from user action to UI change)
- ✅ Real-time sync latency < 1s (when online)
- ✅ Offline-first (no waiting for network)
- ✅ Balance calculations < 5ms for 100+ expenses
- ✅ Use Cases make business logic easy to find
- ✅ Events enable reactive features without coupling
- ✅ Riverpod code generation reduces boilerplate
- ✅ Drift provides type-safe database queries
- ✅ Template method pattern simplifies use case implementation
| Phase | Status | Key Achievement |
|---|---|---|
| 1 | ✅ Complete | Firebase auth, offline DB, basic CRUD |
| 2.1 | ✅ Complete | Use cases with validation |
| 2.2 | ✅ Complete | Event-driven repositories |
| 2.3 | ✅ Complete | Realtime sync with events |
| 2.4 | ✅ Complete | Event-driven reactive providers |
| 2.5 | ✅ Complete | Dependency inversion + 302 tests |
| 3 | 🔄 In Progress | UI integration (balance widgets pending) |
Status: Production-ready backend with complete dependency inversion, comprehensive testing, and event-driven architecture.