Skip to main content

Why This Architecture Exists

Canthus optimizes for four constraints:
  • Local-first reliability: core workflows must work offline.
  • Deterministic business logic: same inputs should produce the same domain outputs.
  • Replaceable infrastructure: database, logging, and integration details should be swappable.
  • Low-coupling UI: widgets should render state and send intents, not run business rules.
The operating rule is one-way flow: UI -> ViewModel -> Application Action -> Repository Contract -> Data Adapter -> Drift

Layer Model & Feature Slices

The app is organized into two primary root directories under app/lib/:
  1. core/: shared infrastructure (bootstrap, design tokens/primitives, persistence database, diagnostics logging, notifications, shell routing, and utilities).
  2. features/: self-contained slices representing distinct business concerns (home, mana, tasks, check_ins, orchard, insights, onboarding, settings, profile).
Within each feature slice, the code is structured conceptually:
  • Presentation: Render views (Widgets) and expose state (ViewModels/Providers).
  • Application: Single-use action classes that execute workflows.
  • Domain: Pure business models, rules, and local repository contracts.
[!NOTE] Local feature domain code is the dependency target within each slice. It must not import from presentation, widgets, or persistence/Drift libraries.

Directory Responsibilities

app/lib/
  ├── core/                     # Shared cross-cutting concerns
  │   ├── bootstrap/            # Startup overrides and global Riverpod wiring
  │   ├── design/               # Shared design primitives (AppButton, AppCard)
  │   ├── diagnostics/          # Diagnostics and logging interfaces (AppDiagnostics)
  │   ├── domain/               # Reusable models & repository interfaces
  │   ├── persistence/          # Drift database setup, migrations, and adapters
  │   ├── platform/             # Local notifications & platform service hooks
  │   └── routing/              # Navigation shells and app route tables

  └── features/                 # Feature slices by product concern
      ├── <feature>/
      │   ├── domain/           # Feature models, logic, and local contracts
      │   ├── application/      # Action controllers (CreateX, GetX)
      │   └── presentation/     # UI widgets, screens, and view models
      │       ├── view_models/  # @riverpod state notifiers
      │       ├── providers/    # Reactive state and UI query providers
      │       └── widgets/      # Screen-level and leaf UI components
      └── <feature>/<feature>.dart  # Feature barrel file (the only allowed public entrypoint)
How to read this split:
  • core/domain/*: Reusable models and core repository contracts.
  • core/persistence/repositories/*: Persistence implementations (Drift adapters).
  • features/<feature>/domain/*: Pure domain models and rule checks specific to that feature concern.
  • features/<feature>/application/*: Single-purpose workflow actions (e.g. CreateTask, SaveCheckIn).

Dependency Rules

Practical implications:
  • Feature code must only depend on another feature through its top-level public barrel file (e.g., import 'package:canthus/features/tasks/tasks.dart'). Deep imports into other feature folders are prohibited.
  • Widgets do not import Drift database classes or query builders directly.
  • Domain models are completely persistence-agnostic (no Drift schema or database row types).
  • Data adapters in core/persistence/ map DB rows to-and-from domain models.

Operation Lifecycle (How It Works)

Read Flow

Reads are routed through view models or query providers that call application read actions to standardize logging, telemetry, and exceptions.

Command Flow

Writes originate from user interactions, triggering methods on AsyncNotifier ViewModels, which call application mutation actions.

Compute Flow (Pacing & Mana Calculations)

Heavy domain calculations (like the mana engine) split input assembly from pure computation to guarantee determinism. Why this split works:
  • Database details stay in persistence adapters.
  • The compute engine remains side-effect-free, replayable, and unit-test friendly.
  • The action boundary orchestrates secondary side-effects (e.g., updating EWMA coefficient snapshots) in one place.

Application Actions: Purpose and Contract

Application actions are the boundary between presentation-driven intent and domain/data execution. Each action should:
  • Represent exactly one system operation.
  • Validate caller inputs.
  • Call repository contracts or domain services.
  • Map exceptions to typed failures using ApplicationFailureMapper.
  • Emit structured logs via AppDiagnostics.
  • Return ApplicationResult<T> (Success<T> or Failure<T>).
See the action result contract in application_result.dart.

Repository Contracts vs Adapters

Repository contracts define what the domain needs, not how storage operates.
  • Contracts live in lib/core/domain/repositories/ or lib/features/<feature>/domain/.
  • Implementations (Adapters) live in lib/core/persistence/repositories/.
Adapters own SQL/Drift queries, transaction boundaries, model mapping, and event appending. They do not own business rules or UI behavior.

Composition Root (Provider Wiring)

Providers that tie infrastructure together live under lib/core/bootstrap/providers/:
  • repository_providers.dart: Instantiates persistence adapters.
  • logger_provider.dart & settings_providers.dart: Global diagnostic logs and SharedPreferences.
  • Feature actions (e.g., task_actions_providers.dart, mana_actions_providers.dart): Centralizes action class instantiations.
ViewModels and presentation query providers live in feature subfolders (e.g. lib/features/tasks/presentation/providers/) and call action providers.

Why It Works

  1. Change Isolation: Code boundaries constrain modifications. SQL updates remain in core/persistence/, business calculations in domain/ or features/<feature>/domain/, and layouts in presentation/.
  2. Testability: Tests map directly to structural boundaries:
    • test/data: Drift adapter and DB transaction behavior.
    • test/domain: Core domain models and orchestration.
    • test/features: Feature-specific domain and application logic.
    • test/ui: ViewModels and UI widget verification.
  3. Determinism: Computation remains separated from database/UI, enabling replayable mana evaluations.

Rules for Adding New Code

Adding a Read Capability

  1. Add/extend the contract in the relevant domain repository file.
  2. Implement the read in the concrete Drift repository under core/persistence/repositories/.
  3. Add a Get* or Watch* action in lib/features/<feature>/application/.
  4. Register the action provider in lib/core/bootstrap/providers/.
  5. Consume the provider in feature view models or UI screens.

Adding a Command/Write Capability

  1. Extend the repository write API contract.
  2. Implement transaction details in the concrete Drift adapter.
  3. Add a mutation action in lib/features/<feature>/application/ wrapping inputs, logs, and results.
  4. Expose ViewModel method invoking this action.
  5. Trigger the ViewModel from the UI widget.

Adding a Domain Policy/Calculation

  1. Add the policy or helper model in lib/features/<feature>/domain/ (or lib/features/mana/domain/engine/).
  2. Write unit tests for the policy in test/features/ or test/domain/.
  3. Wire the policy into application actions.

Common Pitfalls (And Correct Placement)

PitfallWhy it hurtsCorrect place
Widget runs domain formulaHard to test, duplicates styling/pacing rulesdomain/ or features/X/domain/
Action contains SQL queriesHarder to replace persistence/mock testscore/persistence/repositories/
Data adapter decides policyHidden behavior, brittle couplingdomain/ or features/X/domain/
UI reads repository directlyBypasses validation, logging, and diagnosticsAction + provider path
Domain imports data/UI typesCircular coupling riskMap at data adapter boundary