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.
UI -> ViewModel -> Application Action -> Repository Contract -> Data Adapter -> Drift
Layer Model & Feature Slices
The app is organized into two primary root directories underapp/lib/:
core/: shared infrastructure (bootstrap, design tokens/primitives, persistence database, diagnostics logging, notifications, shell routing, and utilities).features/: self-contained slices representing distinct business concerns (home,mana,tasks,check_ins,orchard,insights,onboarding,settings,profile).
- 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
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 onAsyncNotifier 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>orFailure<T>).
Repository Contracts vs Adapters
Repository contracts define what the domain needs, not how storage operates.- Contracts live in
lib/core/domain/repositories/orlib/features/<feature>/domain/. - Implementations (Adapters) live in
lib/core/persistence/repositories/.
Composition Root (Provider Wiring)
Providers that tie infrastructure together live underlib/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.
lib/features/tasks/presentation/providers/) and call action providers.
Why It Works
- Change Isolation: Code boundaries constrain modifications. SQL updates remain in
core/persistence/, business calculations indomain/orfeatures/<feature>/domain/, and layouts inpresentation/. - 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.
- Determinism: Computation remains separated from database/UI, enabling replayable mana evaluations.
Rules for Adding New Code
Adding a Read Capability
- Add/extend the contract in the relevant domain repository file.
- Implement the read in the concrete Drift repository under
core/persistence/repositories/. - Add a
Get*orWatch*action inlib/features/<feature>/application/. - Register the action provider in
lib/core/bootstrap/providers/. - Consume the provider in feature view models or UI screens.
Adding a Command/Write Capability
- Extend the repository write API contract.
- Implement transaction details in the concrete Drift adapter.
- Add a mutation action in
lib/features/<feature>/application/wrapping inputs, logs, and results. - Expose ViewModel method invoking this action.
- Trigger the ViewModel from the UI widget.
Adding a Domain Policy/Calculation
- Add the policy or helper model in
lib/features/<feature>/domain/(orlib/features/mana/domain/engine/). - Write unit tests for the policy in
test/features/ortest/domain/. - Wire the policy into application actions.
Common Pitfalls (And Correct Placement)
| Pitfall | Why it hurts | Correct place |
|---|---|---|
| Widget runs domain formula | Hard to test, duplicates styling/pacing rules | domain/ or features/X/domain/ |
| Action contains SQL queries | Harder to replace persistence/mock tests | core/persistence/repositories/ |
| Data adapter decides policy | Hidden behavior, brittle coupling | domain/ or features/X/domain/ |
| UI reads repository directly | Bypasses validation, logging, and diagnostics | Action + provider path |
| Domain imports data/UI types | Circular coupling risk | Map at data adapter boundary |