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-rule

Decision

Every app feature and every core_* 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 typed AppFailure, see ADR-0040).
  • presentation — widgets + Riverpod controllers. Depends on domain; never imports data directly.
Package dependency DAG (HARD RULE — dependencies point inward):
apps/*             ▶ any package
core_notifications ▶ core_models, core_network, core_database
core_location      ▶ core_models, core_network        (FieldForce app only)
core_ui            ▶ core_models                       (+ Flutter)
core_auth          ▶ core_models, core_network
core_database      ▶ core_models
core_network       ▶ core_models
core_models        ▶ (nothing — pure Dart, no Flutter import)
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, all data/ 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_models must 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_models imports nothing (no Flutter).
  • domain is pure Dart; presentation never imports data directly; try/catch only 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).