ADR-0039: Clean Architecture with an inward-pointing package dependency DAG
Status
Accepted (pending FieldForce Phase 1 implementation)Tags
fieldforce, digital-worker, mobile, flutter, architecture, clean-architecture, hexagonal, ddd, dependency-ruleDecision
Every app feature and everycore_* package follows Clean Architecture (domain / data / presentation) with feature-first folders, and the workspace enforces a single inward-pointing dependency rule at both the feature level and the package level — the client-side expression of the backend’s hexagonal/ports-and-adapters approach.
Layers (inside any module):
- domain — entities, abstract repository interfaces (ports), use cases. Pure Dart. Depends only on
core_models. - data — DTOs, datasources, repository implementations (adapters), mappers. The only layer allowed
try/catch(converts exceptions to typedAppFailure, see ADR-0040). - presentation — widgets + Riverpod controllers. Depends on domain; never imports data directly.
core_models is the innermost core: pure Dart, no Flutter, no other package. Everything points inward at it.
Pragmatic use-case rule: collapse the use-case layer for trivial CRUD (controller may call a repository port directly); introduce explicit use cases only where real domain logic lives (e.g. Digital Worker extraction/drafting). No ceremonial pass-through use cases.
Why
The backend already runs on hexagonal architecture with a dependency-inversion core; the team thinks in ports and adapters and values the testability and substitutability it yields. Applying the same rule client-side gives the same benefits: the offline + realtime data sources (PowerSync, API, location) are driven adapters behind domain ports, so the domain and presentation never know whether data came from cache, sync, or network. Swapping or mocking a data source touches no business logic. Expressing the rule at the package level (not just inside one app) makes it compiler-enforced:core_models literally cannot import Flutter or another package, so the dependency direction can’t silently rot. Under a vibe-coded workflow this matters more than usual — the package graph forbids the LLM from generating an inward-rule violation, so the architecture is enforced by the build, not only by documentation.
Rejected alternatives:
- Layer-first folders (all
domain/together, alldata/together). Scales worse than feature-first as the app grows and doesn’t match bounded-context thinking. Rejected. - MVC/MVVM without a domain core. Simpler, but couples UI to data sources and loses the substitutability that offline+realtime needs. Rejected.
- Full Clean Architecture with use cases for every feature. Over-engineered for trivial CRUD. Rejected in favor of the pragmatic collapse rule.
- Convention-only boundaries (single package,
lib/core/). Not compiler-enforced; relies on discipline the LLM won’t reliably keep. Rejected (see ADR-0038).
Consequences
- More files per feature (the domain/data/presentation triad) — accepted for testability and clean substitution.
core_modelsmust stay dependency-free; any Flutter or package import in it is a design break.- The data layer is the single error-conversion boundary (ADR-0040), keeping error flow predictable.
- New packages must declare their place in the DAG; sideways/inward-violating imports are forbidden and should fail review/build.
Rules for agents
- Respect the package dependency DAG; never create an inward-rule violation.
core_modelsimports nothing (no Flutter). - domain is pure Dart; presentation never imports data directly;
try/catchonly in the data layer. - Repositories expose ports in domain, implementations in data; UI depends on ports.
- Collapse use cases for trivial CRUD; add explicit use cases only for real domain logic.
- Prefer reactive repository contracts (
Stream<T>) for synced data so realtime “just works” (ADR-0039… see PowerSync ADR-0040… reads as Stream).