Files
HouseHoldKeaper/.planning/research/ARCHITECTURE.md

22 KiB
Raw Blame History

Architecture Research

Domain: Local-first Flutter household chore management app Researched: 2026-03-15 Confidence: HIGH (cross-verified: official Flutter docs, CodeWithAndrea, Drift official docs, multiple community templates)

Standard Architecture

System Overview

┌─────────────────────────────────────────────────────────────────┐
│                      PRESENTATION LAYER                         │
│  ┌────────────┐  ┌────────────┐  ┌────────────┐  ┌──────────┐  │
│  │  Screens   │  │  Widgets   │  │ Notifiers  │  │ Providers│  │
│  │(RoomList,  │  │(TaskCard,  │  │(AsyncNotif │  │(wiring   │  │
│  │ DailyPlan) │  │ RoomCard)  │  │ -erProvider│  │ DI)      │  │
│  └─────┬──────┘  └─────┬──────┘  └─────┬──────┘  └────┬─────┘  │
└────────┼───────────────┼───────────────┼───────────────┼────────┘
         │               │               │               │
         ▼               ▼               ▼               ▼
┌─────────────────────────────────────────────────────────────────┐
│                       DOMAIN LAYER                              │
│  ┌────────────┐  ┌────────────┐  ┌────────────────────────────┐ │
│  │  Entities  │  │ Repository │  │       Use Cases            │ │
│  │(Room,Task, │  │ Interfaces │  │(CompleteTask, GetDailyPlan,│ │
│  │ Completion)│  │(abstract)  │  │ ScheduleNextDue)           │ │
│  └────────────┘  └────────────┘  └────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
         │               │               │
         ▼               ▼               ▼
┌─────────────────────────────────────────────────────────────────┐
│                        DATA LAYER                               │
│  ┌───────────────┐  ┌──────────────┐  ┌───────────────────┐    │
│  │  Drift DAOs   │  │  Repository  │  │ Notification Data │    │
│  │(RoomDao,      │  │  Impls       │  │ Source            │    │
│  │ TaskDao,      │  │(RoomRepo,    │  │(flutter_local_    │    │
│  │ CompletionDao)│  │ TaskRepo)    │  │ notifications)    │    │
│  └───────┬───────┘  └──────┬───────┘  └─────────┬─────────┘    │
└──────────┼─────────────────┼─────────────────────┼─────────────┘
           │                 │                     │
           ▼                 ▼                     ▼
┌─────────────────────────────────────────────────────────────────┐
│                     INFRASTRUCTURE LAYER                        │
│  ┌──────────────────────────────────────────────────────────┐   │
│  │              AppDatabase (Drift / SQLite)                 │   │
│  │  Tables: rooms | tasks | task_completions | templates     │   │
│  └──────────────────────────────────────────────────────────┘   │
└─────────────────────────────────────────────────────────────────┘

Component Responsibilities

Component Responsibility Typical Implementation
Screen Renders a full page; reads Riverpod providers ConsumerWidget or ConsumerStatefulWidget
Widget Reusable UI component; dumb or lightly reactive Stateless/Consumer widget
AsyncNotifier Manages async state for a feature; exposes methods for mutations AsyncNotifier<T> subclass with @riverpod annotation
StreamProvider Exposes a reactive Drift .watch() stream to the UI @riverpod Stream<List<T>> ...
Entity Immutable business object; no framework deps Plain Dart class, often @freezed
Repository interface Defines data contract; domain stays unaware of SQLite Abstract Dart class
Repository impl Implements interface using Drift DAOs; translates DB rows to entities Concrete class in data/
DAO Type-safe SQL operations for one table group @DriftAccessor class
AppDatabase Drift database root; holds all tables and registers DAOs @DriftDatabase extending _$AppDatabase
NotificationService Schedules/cancels local OS notifications Wraps flutter_local_notifications; injected via Riverpod
lib/
├── core/
│   ├── database/
│   │   ├── app_database.dart          # @DriftDatabase — root DB class
│   │   ├── app_database.g.dart        # Generated by build_runner
│   │   └── database_provider.dart     # Riverpod Provider<AppDatabase>
│   ├── notifications/
│   │   ├── notification_service.dart  # Wraps flutter_local_notifications
│   │   └── notification_provider.dart # Riverpod provider for service
│   ├── theme/
│   │   └── app_theme.dart             # Material 3 color scheme, typography
│   └── l10n/
│       └── app_de.arb                 # German strings
│
├── features/
│   ├── rooms/
│   │   ├── data/
│   │   │   ├── room_dao.dart          # @DriftAccessor for rooms table
│   │   │   ├── room_dao.g.dart
│   │   │   └── room_repository_impl.dart
│   │   ├── domain/
│   │   │   ├── room.dart              # Entity (Freezed)
│   │   │   └── room_repository.dart   # Abstract interface
│   │   └── presentation/
│   │       ├── rooms_screen.dart
│   │       ├── room_detail_screen.dart
│   │       ├── rooms_provider.dart    # StreamProvider watching rooms
│   │       └── widgets/
│   │           └── room_card.dart
│   │
│   ├── tasks/
│   │   ├── data/
│   │   │   ├── task_dao.dart
│   │   │   ├── task_dao.g.dart
│   │   │   └── task_repository_impl.dart
│   │   ├── domain/
│   │   │   ├── task.dart              # Entity (Freezed)
│   │   │   ├── task_repository.dart
│   │   │   └── scheduling_service.dart # due-date recurrence logic
│   │   └── presentation/
│   │       ├── task_list_screen.dart
│   │       ├── task_form_screen.dart
│   │       ├── tasks_provider.dart
│   │       └── widgets/
│   │           └── task_card.dart
│   │
│   ├── daily_plan/
│   │   ├── domain/
│   │   │   └── daily_plan_service.dart # Query: overdue/today/upcoming
│   │   └── presentation/
│   │       ├── daily_plan_screen.dart
│   │       └── daily_plan_provider.dart
│   │
│   ├── completions/
│   │   ├── data/
│   │   │   ├── completion_dao.dart
│   │   │   └── completion_repository_impl.dart
│   │   └── domain/
│   │       ├── completion.dart
│   │       └── completion_repository.dart
│   │
│   └── templates/
│       ├── data/
│       │   └── bundled_templates.dart  # Static template data (German)
│       └── domain/
│           └── task_template.dart
│
└── main.dart                           # ProviderScope root; bootstrap DB

Structure Rationale

  • core/: Houses exactly two cross-feature concerns — the shared AppDatabase instance and the NotificationService. These must be singletons shared across features, so they belong outside any feature folder. Theme and l10n are also here.
  • features/: Each feature is fully self-contained with its own data/domain/presentation split. This lets you develop and test tasks/ without touching rooms/, and makes the build order obvious — domain defines contracts first, data implements them, presentation consumes.
  • data/ layer per feature: Drift DAOs live here. One DAO per table group (rooms, tasks, completions). The repository impl translates between Drift-generated row objects and domain entities.
  • No usecases/ folder initially: For an app this size, use-case logic (e.g., "complete a task and compute next due date") lives in domain service classes or directly in AsyncNotifier.build/methods. Full use-case classes are appropriate at larger scale but add boilerplate overhead at MVP stage.

Architectural Patterns

Pattern 1: Reactive Drift Streams via StreamProvider

What: Drift's .watch() returns a Stream<List<T>> that emits on every relevant DB change. Expose this directly via a Riverpod StreamProvider so the UI rebuilds automatically without polling or manual refresh calls.

When to use: All read operations — room list, task list, daily plan. Anything the user needs to see stay current.

Trade-offs: Simple, reactive, zero caching complexity. Works perfectly for purely local data. Would require adjustment if a sync layer were added later.

Example:

// features/tasks/presentation/tasks_provider.dart
@riverpod
Stream<List<Task>> tasksByRoom(TasksByRoomRef ref, int roomId) {
  final dao = ref.watch(databaseProvider).taskDao;
  return dao.watchTasksByRoom(roomId).map(
    (rows) => rows.map(Task.fromRow).toList(),
  );
}

// features/tasks/data/task_dao.dart
@DriftAccessor(tables: [Tasks])
class TaskDao extends DatabaseAccessor<AppDatabase> with _$TaskDaoMixin {
  TaskDao(super.db);

  Stream<List<TaskData>> watchTasksByRoom(int roomId) =>
    (select(tasks)..where((t) => t.roomId.equals(roomId))
                  ..orderBy([(t) => OrderingTerm(expression: t.dueDate)]))
    .watch();
}

Pattern 2: AsyncNotifier for Mutations

What: Mutations (create, update, delete, complete) go through an AsyncNotifier. The notifier holds no derived state — it calls the repository and lets the StreamProvider propagate the DB change automatically.

When to use: Any write operation — creating a room, completing a task, editing task metadata.

Trade-offs: Clean separation between reads (StreamProvider) and writes (AsyncNotifier). The notifier is thin; no manual state synchronization needed because Drift streams handle it.

Example:

@riverpod
class TaskNotifier extends _$TaskNotifier {
  @override
  FutureOr<void> build() {}

  Future<void> completeTask(int taskId) async {
    final repo = ref.read(taskRepositoryProvider);
    await repo.completeTask(taskId); // writes completion + updates dueDate
    // No setState needed — Stream from watchTasksByRoom emits automatically
  }
}

Pattern 3: Single AppDatabase Singleton via Riverpod

What: One Provider<AppDatabase> at the application root. All DAOs are accessed as getters on this instance (db.taskDao, db.roomDao). The provider registers ref.onDispose(db.close) to clean up.

When to use: Always. Multiple DB instances will corrupt SQLite state.

Trade-offs: Simple and correct. The singleton approach works well for a single-device, offline app. Would need adjustment only if the schema were split across multiple databases (rare for apps this size).

Example:

// core/database/database_provider.dart
@riverpod
AppDatabase appDatabase(AppDatabaseRef ref) {
  final db = AppDatabase();
  ref.onDispose(db.close);
  return db;
}

Data Flow

Read Flow (Reactive)

User opens screen
    ↓
ConsumerWidget watches StreamProvider
    ↓
StreamProvider subscribes to DAO .watch() stream
    ↓
Drift executes SQL SELECT, returns Stream<List<Row>>
    ↓
Row objects mapped to domain Entities in provider
    ↓
Widget renders from AsyncValue<List<Entity>>
    ↓
[Any DB write elsewhere]
    ↓
Drift detects table change, emits new list to all watchers
    ↓
Widget rebuilds automatically

Write Flow (Mutation)

User taps "Mark Done"
    ↓
Widget calls ref.read(taskNotifierProvider.notifier).completeTask(id)
    ↓
AsyncNotifier calls TaskRepository.completeTask(id)
    ↓
Repository impl calls TaskDao.insertCompletion() + TaskDao.updateNextDue()
    ↓
Drift writes to SQLite
    ↓
All active .watch() streams for affected tables emit updated data
    ↓
Daily plan StreamProvider rebuilds automatically
    ↓
UI reflects completion + new due date

Notification Scheduling Flow

App startup / task completion
    ↓
NotificationService.scheduleDaily(summaryTime)
    ↓
flutter_local_notifications schedules OS alarm
    ↓
(No Riverpod state involved — fire-and-forget side effect)
    ↓
OS delivers notification at scheduled time
    ↓
User taps notification → app opens to DailyPlanScreen

Key Data Flows Summary

  1. Room list: AppDatabase.roomDao.watchAllRooms()StreamProvider<List<Room>>RoomsScreen
  2. Daily plan: AppDatabase.taskDao.watchDueTasks(today)StreamProvider<DailyPlan>DailyPlanScreen (sections: overdue / today / upcoming)
  3. Task completion: TaskNotifierProvider.completeTask()TaskDao.insertCompletion() + updateNextDue() → all watchers update
  4. Cleanliness indicator: Derived in provider from overdue task count per room — computed from watchTasksByRoom stream, no separate table needed
  5. Template seeding: On first launch, main.dart checks AppDatabase for empty tables and seeds from static bundled_templates.dart within a single transaction

Scaling Considerations

This is a single-device offline app. Scaling means "works without degradation as data grows over months/years," not multi-user or distributed concerns.

Scale Architecture Adjustments
< 500 tasks No optimization needed — Drift handles it trivially
5005,000 tasks Add indexes on due_date and room_id in Drift table definitions (add from the start to avoid a migration later)
5,000+ tasks Paginate daily plan queries; add LIMIT/OFFSET or cursor-based pagination in DAOs
Task history growth Completion log can grow unbounded. Archive completions older than 1 year in a separate table or apply DELETE WHERE created_at < X on startup

Scaling Priorities

  1. First bottleneck: Drift .watch() queries that return very large lists — fix with LIMIT and proper indexes
  2. Second bottleneck: Notification scheduling on a large task set — fix by only scheduling the next N upcoming tasks rather than all tasks

Anti-Patterns

Anti-Pattern 1: Accessing AppDatabase directly in widgets

What people do: ref.watch(appDatabaseProvider).taskDao.watchAllTasks() inside a build() method.

Why it's wrong: Creates tight coupling between UI and infrastructure. Bypasses the repository pattern. Makes the widget untestable without a real database. Breaks the dependency rule — presentation should not know about Drift.

Do this instead: Always go through a StreamProvider or AsyncNotifier that wraps the DAO call. Widgets only ref.watch(tasksByRoomProvider(roomId)).

Anti-Pattern 2: Multiple AppDatabase instances

What people do: Calling AppDatabase() in multiple places — e.g., inside each DAO file or feature-level provider.

Why it's wrong: SQLite with multiple connections to the same file causes locking errors and data corruption. Drift will throw at runtime.

Do this instead: One @riverpod AppDatabase appDatabase(...) provider at the app root. All DAOs are accessed as getters on ref.watch(appDatabaseProvider).

Anti-Pattern 3: Storing computed state in the database

What people do: Adding a cleanliness_score column that is updated on every task completion.

Why it's wrong: Derived data that can be computed from existing rows should never be stored. It creates consistency bugs (score can drift out of sync with actual task states). It adds unnecessary write operations.

Do this instead: Compute cleanliness score in a Riverpod provider that derives from the watchTasksByRoom stream. Pure computation, always consistent.

Anti-Pattern 4: Using ref.read in widget build() for streams

What people do: final tasks = ref.read(tasksProvider) inside build() to avoid rebuilds.

Why it's wrong: ref.read does not subscribe — the widget will not update when the stream emits new data. This defeats the entire point of reactive streams.

Do this instead: Always ref.watch(tasksProvider) in build(). To avoid excessive rebuilds, use ref.select() or split into smaller widgets.

Anti-Pattern 5: Business logic in Drift DAOs

What people do: Putting "complete task and compute next due date" entirely inside the DAO.

Why it's wrong: DAOs should be thin SQL wrappers. Business rules (recurrence calculation, overdue logic) belong in the domain layer — SchedulingService or within TaskRepository. DAOs that contain business logic are harder to test and break the Clean Architecture dependency rule.

Do this instead: DAO handles SQL. Repository impl calls DAO + domain service. Domain service contains recurrence math.

Integration Points

Internal Boundaries

Boundary Communication Notes
Presentation -> Domain Riverpod providers (read/watch repository interfaces) Presentation never imports from data/ directly
Domain -> Data Repository interface (abstract class) Domain defines interface; data implements it
Data -> Infrastructure Drift DAO methods DAO is the only thing that knows about table/column names
Notifications -> Domain NotificationService injected via Riverpod; called from TaskNotifier after writes Notifications are a side effect, not part of state
Templates -> Database Seeded once on first launch via AppDatabase.transaction() Static data; not a runtime concern

Build Order (Dependency Sequence)

Components must be built in dependency order. Later steps depend on earlier ones:

  1. AppDatabase schema — tables, migrations; everything else depends on this
  2. DAOs — generated from table definitions; needed by repository impls
  3. Domain entities + repository interfaces — pure Dart; no framework deps
  4. Repository implementations — depend on DAOs and domain entities
  5. Riverpod providers — wire database and repositories; depend on both
  6. Domain services — scheduling, daily plan computation; depend on entities
  7. Feature notifiers — depend on repository providers and domain services
  8. Screens and widgets — depend on notifiers and stream providers
  9. NotificationService — can be built any time after step 1; integrated as a side effect in step 7
  10. Template seeding — runs at app startup after AppDatabase is available

Sources


Architecture research for: Local-first Flutter household chore management app (HouseHoldKeaper) Researched: 2026-03-15