From aa21aec4f5b8c97f5ba4e53f948c74d752e50132 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9my=20Baudet?= Date: Wed, 2 Sep 2026 15:01:44 -0600 Subject: [PATCH 1/8] feat: source FFCA conventions from VGV Engineering The canonical FFCA documentation moved from Notion to VGV Engineering, where it is now split across seven pages and has picked up new material. Every page on engineering.verygood.ventures serves a clean Markdown twin at the same path with a .md extension, so references/ffca/ is now a byte mirror of upstream rather than a hand-maintained paraphrase. sync_reference.dart fetches it, --check reports drift, and a scheduled workflow runs that check weekly. It is scheduled rather than a PR gate so an upstream edit cannot fail an unrelated contributor's build. Content the skills now teach, following upstream: - Command and Query replace "use case" for the classes in use_cases/. The folder keeps its conventional name; the classes do not. - Presentation-only features, a _presentation package with no domain or data sibling, composing other features' domains into a screen. - Deferred loading: a package the app loads deferred must not also be reachable eagerly, and the failure is silent. - Widget slots as visual extension points, the conditions on sharing a widget across features, and where a shared widget should live. - Split routing tables must be part of one library or go_router_builder drops the routes without failing the build. - Converter classes for DTO mapping, and Provider as the module standard. Dropped with upstream: Actions and Intents, Makefile, Non-Goals, Open Discussions. The layer policy itself is unchanged, so validate_layers.dart needs no new rules. A presentation-only fixture locks that archetype into the test suite. Skill citations now name a file and a section, and are qualified with ${CLAUDE_PLUGIN_ROOT} so they resolve from the plugin rather than the user's working directory. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01SNHNDnPH7H7y2m4Pbsbmme --- .github/workflows/ci.yaml | 2 +- .github/workflows/reference_drift.yaml | 24 + README.md | 35 +- agents/ffca-layer-auditor.md | 32 +- config/cspell.json | 7 +- references/code_templates/data_templates.md | 43 +- references/code_templates/domain_templates.md | 26 +- .../code_templates/presentation_templates.md | 105 ++- references/ffca/README.md | 33 + references/ffca/data.md | 69 ++ references/ffca/domain.md | 119 +++ references/ffca/faq.md | 205 +++++ references/ffca/navigation.md | 276 +++++++ references/ffca/overview.md | 171 ++++ references/ffca/presentation.md | 206 +++++ references/ffca/project_structure.md | 239 ++++++ references/ffca_architecture.md | 782 ------------------ scripts/sync_reference.dart | 178 +++- .../apps/mobile_app/pubspec.yaml | 2 + .../ideas/ideas_presentation/pubspec.yaml | 14 + scripts/test/validate_layers_test.dart | 13 + scripts/validate_layers.dart | 9 +- skills/ffca-architecture/SKILL.md | 45 +- skills/ffca-audit/SKILL.md | 2 +- skills/ffca-cross-feature/SKILL.md | 53 +- skills/ffca-feature/SKILL.md | 44 +- skills/ffca-routing/SKILL.md | 58 +- 27 files changed, 1876 insertions(+), 916 deletions(-) create mode 100644 .github/workflows/reference_drift.yaml create mode 100644 references/ffca/README.md create mode 100644 references/ffca/data.md create mode 100644 references/ffca/domain.md create mode 100644 references/ffca/faq.md create mode 100644 references/ffca/navigation.md create mode 100644 references/ffca/overview.md create mode 100644 references/ffca/presentation.md create mode 100644 references/ffca/project_structure.md delete mode 100644 references/ffca_architecture.md create mode 100644 scripts/test/fixtures/valid_workspace/features/ideas/ideas_presentation/pubspec.yaml diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 7d6fa3b..72c8254 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -17,7 +17,7 @@ jobs: globs: | **/*.md !CHANGELOG.md - !references/ffca_architecture.md + !references/ffca/** config: 'config/custom.markdownlint.jsonc' spelling: diff --git a/.github/workflows/reference_drift.yaml b/.github/workflows/reference_drift.yaml new file mode 100644 index 0000000..ed6ea8a --- /dev/null +++ b/.github/workflows/reference_drift.yaml @@ -0,0 +1,24 @@ +name: reference drift + +# The FFCA reference under references/ffca/ is a byte mirror of the canonical +# docs on engineering.verygood.ventures. This job re-fetches them and fails when +# the committed mirror has fallen behind. +# +# It runs on a schedule rather than on pull requests on purpose: upstream can +# change at any time, and that should not fail an unrelated contributor's PR. + +on: + schedule: + # Mondays at 07:00 UTC. + - cron: '0 7 * * 1' + workflow_dispatch: + +jobs: + drift: + name: 📚 Reference Drift + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: dart-lang/setup-dart@v1 + - name: Check the mirror against upstream + run: dart run scripts/sync_reference.dart --check diff --git a/README.md b/README.md index 1e33db8..fe72beb 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,30 @@ VGV FFCA Plugin teaches Claude the Feature-First Clean Architecture conventions - **A blocking validation hook** that runs on every `pubspec.yaml` edit and stops the edit when it breaks a layer dependency rule, with the rule and the fix in the message so Claude self-corrects. - **An MCP configuration** that wires the Very Good CLI server for project and package operations. -The conventions themselves live in a single reference, `references/ffca_architecture.md`, mirrored from the canonical Notion page. The skills never restate the conventions: they point into the reference by section name, so the architecture has exactly one source of truth. +The conventions themselves live in `references/ffca/`, a byte mirror of the canonical FFCA documentation on [VGV Engineering](https://engineering.verygood.ventures/architecture/ffca/overview/), regenerated by `scripts/sync_reference.dart`. The skills never restate the conventions: they point into the mirror by file and section, so the architecture has exactly one source of truth. + +## The architecture reference + +`references/ffca/` holds one file per page of the canonical documentation, fetched verbatim from : + +| File | Covers | +| --- | --- | +| `overview.md` | Goals, the three-part structure, feature archetypes, shared libraries, the layer model | +| `domain.md` | Models, Commands and Queries, repository interfaces, composing features, the Summary pattern | +| `data.md` | Data sources, DTOs, converters and mappers | +| `presentation.md` | Modules, localizations, widgets that own state, widget slots, subfeature barrels | +| `navigation.md` | Callback injection, typed routes, splitting the routing table, deep links | +| `project_structure.md` | Dependency rules, deferred loading, naming, folder layout, tooling, add-to-app | +| `faq.md` | Callable classes, auth and user profiles, one big OpenAPI spec, nested objects | + +Do not edit these by hand. Fix the architecture at the source, then sync: + +```bash +dart run scripts/sync_reference.dart # rewrite the mirror +dart run scripts/sync_reference.dart --check # fail if it is stale +``` + +A scheduled [workflow](.github/workflows/reference_drift.yaml) runs `--check` weekly, so the mirror cannot quietly fall behind upstream. ## The stack @@ -49,9 +72,9 @@ The plugin is published in the [Very Good Claude Marketplace](https://github.com | Skill | Description | | --- | --- | | [**FFCA Architecture**](skills/ffca-architecture/SKILL.md) | Orientation: where code lives across `apps/`, `features/`, `shared/`, the naming conventions, the layer dependency rules, and the anti-patterns to reject | -| [**FFCA Feature**](skills/ffca-feature/SKILL.md) | Scaffold and extend a feature: the three-package domain, data, and presentation structure, models, repositories, use cases, DTOs, mappers, Cubits, and Modules | -| [**FFCA Routing**](skills/ffca-routing/SKILL.md) | Navigation: callback injection, `go_router_builder` typed routes, the `$extra` hydration pattern, and the feature-isolation constraints | -| [**FFCA Cross-Feature**](skills/ffca-cross-feature/SKILL.md) | Coupling features: domain-to-domain dependencies, the Summary pattern, use cases that combine repositories, and composing features | +| [**FFCA Feature**](skills/ffca-feature/SKILL.md) | Scaffold and extend a feature: the three-package domain, data, and presentation structure, headless and presentation-only features, models, repositories, Commands and Queries, DTOs, mappers, Cubits, and Modules | +| [**FFCA Routing**](skills/ffca-routing/SKILL.md) | Navigation: callback injection, `go_router_builder` typed routes, splitting the routing table, deferred imports, the `$extra` hydration pattern, and the feature-isolation constraints | +| [**FFCA Cross-Feature**](skills/ffca-cross-feature/SKILL.md) | Coupling features: domain-to-domain dependencies, the Summary pattern, Queries that combine repositories, sharing widgets, widget slots, and composing features | | [**FFCA Audit**](skills/ffca-audit/SKILL.md) | Whole-repo health check: dispatches the `ffca-layer-auditor` agent, which runs the mechanical layer, naming, and cycle checks plus a qualitative review and returns a per-package verdict table | Skills activate automatically when Claude detects an FFCA repo or an FFCA-shaped question. You can also invoke them directly: @@ -87,10 +110,10 @@ dart run scripts/validate_layers.dart --all | Agent | Behavior | | --- | --- | -| [**ffca-layer-auditor**](agents/ffca-layer-auditor.md) | Read-only architecture auditor. Runs the validator in `--all` mode, then adds source-level checks (declared-but-unused dependencies, barrel hygiene, DTO leakage, use-case necessity, module entry, misplaced packages, high fan-in) and returns a per-package verdict table. Reports violations, never auto-fixes | +| [**ffca-layer-auditor**](agents/ffca-layer-auditor.md) | Read-only architecture auditor. Runs the validator in `--all` mode, then adds source-level checks (declared-but-unused dependencies, barrel hygiene, DTO leakage, Command/Query necessity, module entry, split routing tables, deferred-loading reachability, misplaced packages, high fan-in) and returns a per-package verdict table. Reports violations, never auto-fixes | The auditor runs in its own context, so the same architecture review can be dispatched from the `ffca-audit` skill, a refactor, or a pre-PR flow without crowding the main conversation. The per-edit hook prevents bad pubspec dependencies as they are written; the agent answers whether the whole repo is healthy on demand. ## How it fits together -The hook keeps individual pubspec edits compliant. The skills teach the conventions and workflows. The `ffca-layer-auditor` agent answers whether the whole repo is healthy, on demand and reusable across flows. All of them read from the same `references/ffca_architecture.md`, so when the architecture evolves, the reference is the only file that changes. +The hook keeps individual pubspec edits compliant. The skills teach the conventions and workflows. The `ffca-layer-auditor` agent answers whether the whole repo is healthy, on demand and reusable across flows. All of them read from the same `references/ffca/` mirror, so when the architecture evolves, a sync is the only change. diff --git a/agents/ffca-layer-auditor.md b/agents/ffca-layer-auditor.md index 79038c0..ff22eed 100644 --- a/agents/ffca-layer-auditor.md +++ b/agents/ffca-layer-auditor.md @@ -1,7 +1,7 @@ --- name: ffca-layer-auditor description: | - Audits an FFCA monorepo for architecture compliance. Runs the deterministic layer/naming/cycle validator, then adds source-level checks (import usage, barrel hygiene, DTO leakage, use-case necessity, module entry, misplacement, fan-in) and reports a per-package verdict table. Read-only: it reports violations and never auto-fixes. Use proactively after adding or moving packages, after a refactor, and before opening a PR that changes dependencies, and from the ffca-audit skill. + Audits an FFCA monorepo for architecture compliance. Runs the deterministic layer/naming/cycle validator, then adds source-level checks (import usage, barrel hygiene, DTO leakage, Command/Query necessity, module entry, misplacement, fan-in) and reports a per-package verdict table. Read-only: it reports violations and never auto-fixes. Use proactively after adding or moving packages, after a refactor, and before opening a PR that changes dependencies, and from the ffca-audit skill. @@ -17,7 +17,7 @@ description: | user: "Before I open the PR, can you check the whole repo follows FFCA?" assistant: "I'll dispatch the ffca-layer-auditor agent for a full-graph audit and a per-package verdict table." - Pre-PR audits need the mechanical pass plus qualitative checks (import usage, DTO leakage, use-case necessity) that go beyond the pubspec graph. + Pre-PR audits need the mechanical pass plus qualitative checks (import usage, DTO leakage, Command/Query necessity) that go beyond the pubspec graph. @@ -29,7 +29,7 @@ model: inherit You audit a Feature-First Clean Architecture (FFCA) monorepo and report whether it is healthy. You are read-only: you report violations with their fix, you never edit code. -Do not restate the conventions from memory. The rules live in `${CLAUDE_PLUGIN_ROOT}/references/ffca_architecture.md`; read the cited section whenever you need the detail behind a check. If the repo has no `features/` folder, it is not FFCA-shaped: say so and stop. +Do not restate the conventions from memory. The rules live in `${CLAUDE_PLUGIN_ROOT}/references/ffca/`, a byte mirror of ; read the cited file and section whenever you need the detail behind a check. If the repo has no `features/` folder, it is not FFCA-shaped: say so and stop. ## Step 1: mechanical pass (deterministic) @@ -39,20 +39,23 @@ From the repo root, run the validator in full-graph mode and treat every line it dart run ${CLAUDE_PLUGIN_ROOT}/scripts/validate_layers.dart --all ``` -This is the authoritative check for the pubspec dependency graph: package naming under `features/`, the layer dependency rules, the no-dependency-on-an-app rule, and workspace-wide cycles. It prints each violation with its rule and fix and exits 2 if any are found, 0 if the graph is clean. Do not re-derive these checks by hand; the script is the source of truth for them. Read section *Dependency Graph Rules* and *Naming Conventions (Enforced)* only if you need to explain a finding. +This is the authoritative check for the pubspec dependency graph: package naming under `features/`, the layer dependency rules, the no-dependency-on-an-app rule, and workspace-wide cycles. It prints each violation with its rule and fix and exits 2 if any are found, 0 if the graph is clean. Do not re-derive these checks by hand; the script is the source of truth for them. Read `references/ffca/project_structure.md`, sections *Dependency rules* and *Naming conventions*, only if you need to explain a finding. ## Step 2: source-level checks (qualitative) The script validates the declared pubspec graph. These checks need the source, so do them by reading and grepping the tree. Cite the reference section for each. -1. **Package inventory and classification.** Enumerate every package under `apps/`, `features/`, `shared/`. Classify each by folder, name, and inferred type: full feature, headless feature (domain plus data, no presentation), or shared package. See sections *Features*, *Headless Features*, *Shared Libraries*. +1. **Package inventory and classification.** Enumerate every package under `apps/`, `features/`, `shared/`. Classify each by folder, name, and inferred type: full feature, headless feature (domain plus data, no presentation), presentation-only feature (a `_presentation` with no domain or data sibling), or shared package. All four are legitimate; a missing sibling is a signal, not a violation. See `references/ffca/overview.md`, sections *Features*, *Headless features*, *Presentation-only features*, *Shared libraries*. 2. **Declared-but-unused dependencies.** For each package, every path dependency in its pubspec should be imported somewhere in its source. A declared dependency that is never imported is a finding. Pay special attention to presentation packages. -3. **Barrel hygiene.** Each package has a primary barrel `lib/{package}.dart` re-exporting `src/` (or the layer's public files). Features with multiple independent entry points have subfeature barrels. No barrel re-exports a private symbol. Detect private re-exports with `rg -n "^export '.*/_" features shared apps`. See sections *Subfeature Barrel Files* and *Layer Subfolder Conventions*. -4. **DTO leakage.** Generated or transport types (`*.g.dart`, `*.freezed.dart`, anything under a `dtos/` folder) must not be imported outside the data package that owns them. Detect with `rg -n "import .*\.(g|freezed)\.dart'" features shared apps` and check the importing package owns the source. See section *Data Layer*. -5. **Use-case necessity.** A `Query` or `Command` class in a `*_domain` package is justified only when it combines two or more repositories or removes duplication across Blocs. A single-repository pass-through is an anti-pattern. Open each `*_query.dart` / `*_command.dart` and count injected repositories. See section *Domain Layer* (Business Rules) and the FAQ entry on callable classes. -6. **Module entry points.** App route bindings should instantiate a feature `*Module`, not a screen or page widget directly, so dependencies stay explicit. Inspect the app's router and verify each route builds a `*Module`. See section *Presentation Layer* (Entry Point: The Module) and *Routing & Navigation*. -7. **Misplaced packages.** Volatile or app-specific business logic sitting in `shared/`, or a generic, pub-publishable utility sitting in `features/`. Apply the decision rule and component table in section *Shared Libraries*. -8. **High fan-in domains.** A domain many features depend on may be doing too much. Note it for review against section *Combining Different Features*. +3. **Barrel hygiene.** Each package has a primary barrel `lib/{package}.dart` re-exporting `src/` (or the layer's public files). Features with multiple independent entry points have subfeature barrels. No barrel re-exports a private symbol. Detect private re-exports with `rg -n "^export '.*/_" features shared apps`. A widget exported for another feature has its own narrow barrel whose transitive imports do not reach the feature's modules or screens; a cross-feature import that targets the primary barrel is a finding. See `references/ffca/presentation.md`, sections *Subfeature barrel files* and *Sharing a widget across features*, and `references/ffca/project_structure.md`, section *Layer subfolders*. +4. **DTO leakage.** Generated or transport types (`*.g.dart`, `*.freezed.dart`, anything under a `dtos/` folder) must not be imported outside the data package that owns them. Detect with `rg -n "import .*\.(g|freezed)\.dart'" features shared apps` and check the importing package owns the source. See `references/ffca/data.md`, section *DTOs and mappers*. +5. **Command and Query necessity.** A `Query` or `Command` class in a `*_domain` package is justified only when it combines two or more repositories or removes duplication across Blocs. A single-repository pass-through is an anti-pattern. Open each `*_query.dart` / `*_command.dart` and count injected repositories. Also check the verbs: commands expose `execute`, queries expose `get` or `watch`, and neither is a callable class. Classes named `*UseCase` are a naming finding. See `references/ffca/domain.md`, section *Business rules*, and `references/ffca/faq.md`, section *Should we use callable classes for Commands and Queries?* +6. **Module entry points.** App route bindings should instantiate a feature `*Module`, not a screen or page widget directly, so dependencies stay explicit. Inspect the app's router and verify each route builds a `*Module`. See `references/ffca/presentation.md`, section *The module*, and `references/ffca/navigation.md`. +7. **Misplaced packages.** Volatile or app-specific business logic sitting in `shared/`, or a generic, pub-publishable utility sitting in `features/`. Apply the decision rule and component table in `references/ffca/overview.md`, section *Shared libraries*. +8. **High fan-in domains.** A domain many features depend on may be doing too much. Note it for review against `references/ffca/domain.md`, section *Composing features*. + +9. **Split routing tables.** If the app's routes live in more than one file, every file but the entry library must begin with `part of`. A per-feature route file that is a separate library is silently dropped by `go_router_builder`, and the build still succeeds. Detect with `rg -L "^part of" apps/*/lib/**/[a-z_]*routes.dart`. See `references/ffca/navigation.md`, section *Splitting the routing table across files*. +10. **Deferred loading reachability.** For each feature the app imports with a `deferred as` prefix, check that no non-deferred import path from the app reaches the same package. An eager edge, typically one presentation package importing another, silently cancels the code splitting. Advisory on apps that ship only to iOS and Android. See `references/ffca/project_structure.md`, section *Deferred loading*. ## Step 3: report @@ -66,8 +69,9 @@ Produce a per-package verdict table, then a prioritized findings list. Do not au Verdict is `pass`, `warn`, or `fail`. Order findings by severity: 1. Layer-rule and cycle violations from the mechanical pass (these break the architecture). -2. Naming violations. -3. DTO leakage, barrel gaps, declared-but-unused dependencies, use-case and module-entry violations. -4. Advisory notes: misplacement and high fan-in. +2. Silent-failure violations: a route file that is not `part of` the routing library, and a deferred package reachable eagerly. Nothing breaks at build time, so nothing else will catch them. +3. Naming violations. +4. DTO leakage, barrel gaps, declared-but-unused dependencies, Command/Query and module-entry violations. +5. Advisory notes: misplacement and high fan-in. For each finding, give `file:line`, the bad shape, and the required fix, the same way the mechanical pass does. End with a single status line: `PASS` or `FAIL (N violations across M packages)`. diff --git a/config/cspell.json b/config/cspell.json index 4536f76..2428900 100644 --- a/config/cspell.json +++ b/config/cspell.json @@ -1,9 +1,13 @@ { "language": "en", + "ignorePaths": [ + "**/references/ffca/**" + ], "words": [ "dtos", "ffca", "mappr", + "mealify", "mocktail", "operationalizes", "posthog", @@ -12,7 +16,8 @@ "rebuildable", "riverpod", "subcomponents", - "subfeature" + "subfeature", + "subfolders" ], "flagWords": [] } diff --git a/references/code_templates/data_templates.md b/references/code_templates/data_templates.md index 0e8dd1e..eadd4e5 100644 --- a/references/code_templates/data_templates.md +++ b/references/code_templates/data_templates.md @@ -1,6 +1,6 @@ # Data Layer Templates -Ready-to-adapt code for a `{feature}_data` package. Adapted from the conventions in `references/ffca_architecture.md`, section *Data Layer* (read it for the rules: DTOs stay inside the data layer, mappers convert DTOs to domain models, the repository mixes data sources to fulfil the domain interface). For a backend-specific data layer, name the package `{feature}_data_{backend}`. +Ready-to-adapt code for a `{feature}_data` package. Adapted from the conventions in `references/ffca/data.md` (read it for the rules: DTOs stay inside the data layer, converters and mappers turn DTOs into domain models, the repository mixes data sources to fulfil the domain interface). For a backend-specific data layer, name the package `{feature}_data_{backend}`. ## DTO (`data_sources/{source}/dtos/`) @@ -42,7 +42,33 @@ class ProductsRemoteDataSource { ## Mapper (`mappers/`) -An extension method converting a DTO to its domain model. Hand or AI written, or generated with a tool such as `auto_mappr`. Do not define an abstract DTO across storage options. +Converts a DTO to its domain model, so the DTO never escapes the package. Prefer a `Converter` subclass: it keeps the mapping in one named, testable place and reads well at the call site. Extension methods and generators such as `auto_mappr` are also fine. Do not define an abstract DTO across storage options. + +One converter per source and per direction. A feature reading from a database and an API has a `DbToDomain...` and an `ApiToDomain...`. + +```dart +// product_data/lib/src/mappers/api_to_domain_product_converter.dart + +/// Converts the API response into the domain model. +class ApiToDomainProductConverter extends Converter { + /// Construct a converter from API products to domain products. + const ApiToDomainProductConverter(); + + @override + Product convert(ProductDto dto) { + return Product( + id: dto.id, + title: dto.title, + description: dto.description, + // The API sends cents as an integer. The domain works in whole currency + // units, so the conversion belongs here, not in a Bloc. + price: dto.priceInCents / 100, + ); + } +} +``` + +The extension-method form, where a `Converter` is more ceremony than the mapping deserves: ```dart extension ProductDtoMapper on ProductDto { @@ -56,15 +82,20 @@ Implements the domain interface by mixing data sources and mapping their DTOs to ```dart class ProductsRepository implements IProductsRepository { - ProductsRepository({required ProductsRemoteDataSource remoteDataSource}) - : _remoteDataSource = remoteDataSource; + ProductsRepository({ + required ProductsRemoteDataSource remoteDataSource, + ApiToDomainProductConverter converter = const ApiToDomainProductConverter(), + }) : _remoteDataSource = remoteDataSource, + _converter = converter; final ProductsRemoteDataSource _remoteDataSource; + final ApiToDomainProductConverter _converter; + @override Future getProductById(String productId) async { final dto = await _remoteDataSource.fetchProduct(productId); - return dto.toDomain(); + return _converter.convert(dto); } // Implement the remaining IProductsRepository members. @@ -73,7 +104,7 @@ class ProductsRepository implements IProductsRepository { ## Normalizing nested API objects -When an API returns another feature's data nested inside this one, the repository depends on that feature's domain interface (a valid data to domain dependency), splits the nested object out, and stores the local model as a summary of ids. The DTO-to-domain mapping for the nested object lives here, not imported from the other feature's data package. Read the FAQ entry *Dealing with nested API objects*. +When an API returns another feature's data nested inside this one, the repository depends on that feature's domain interface (a valid data to domain dependency), splits the nested object out, and stores the local model as a summary of ids. The DTO-to-domain mapping for the nested object lives here, not imported from the other feature's data package. Read `references/ffca/faq.md`, section *Dealing with nested objects*. ## Barrel (`lib/{feature}_data.dart`) diff --git a/references/code_templates/domain_templates.md b/references/code_templates/domain_templates.md index df0598e..2cca008 100644 --- a/references/code_templates/domain_templates.md +++ b/references/code_templates/domain_templates.md @@ -1,6 +1,6 @@ # Domain Layer Templates -Ready-to-adapt code for a `{feature}_domain` package. Extracted from `references/ffca_architecture.md`. Read sections *Domain Layer* and *Combining Different Features* for the rules these shapes follow. Rename `Product`/`Cart` to your aggregate and drop the package name into the barrel. +Ready-to-adapt code for a `{feature}_domain` package. Extracted from `references/ffca/domain.md`. Read sections *Business rules*, *Repositories*, and *Composing features* for the rules these shapes follow. Rename `Product`/`Cart` to your aggregate and drop the package name into the barrel. ## Model (`models/`) @@ -63,9 +63,11 @@ abstract interface class ICartsRepository { } ``` -## Use case combining repositories (`use_cases/`) +## Command and Query combining repositories (`use_cases/`) -Add a `Query` or `Command` only when you combine multiple repositories or repeat work across Blocs. Verbs: `get`/`watch` for queries, `execute` for commands. Never callable classes. +The folder keeps the conventional `use_cases/` name so the layout matches other clean architecture projects, but the classes are named **Command** and **Query**. Do not name a class `*UseCase`. + +Add one only when you combine multiple repositories or repeat work across Blocs. A pass-through to a single repository needs no class at all. Verbs: `get`/`watch` for queries, `execute` for commands. Never callable classes: `call` breaks find-usages and jump-to-definition. ```dart class GetCartByIdQuery { @@ -92,9 +94,25 @@ class GetCartByIdQuery { } ``` +A Command looks the same, with an `execute` method that returns the result of the mutation: + +```dart +class UpdateProductTitleCommand { + UpdateProductTitleCommand({required IProductsRepository productsRepository}) + : _productsRepository = productsRepository; + + final IProductsRepository _productsRepository; + + Future execute(String productId, String title) async { + final product = await _productsRepository.getProductById(productId); + await _productsRepository.updateProduct(product.copyWith(title: title)); + } +} +``` + ## Identity versus entity (separate domains) -Authentication and profile are separate features. The consuming feature's domain glues them with a query. See the FAQ entry *How do I handle Auth and User Profiles?*. +Authentication and profile are separate features. The consuming feature's domain glues them with a Query. See `references/ffca/faq.md`, section *How do I handle auth and user profiles?*. ```dart // auth_domain diff --git a/references/code_templates/presentation_templates.md b/references/code_templates/presentation_templates.md index 3a9982c..04f9545 100644 --- a/references/code_templates/presentation_templates.md +++ b/references/code_templates/presentation_templates.md @@ -1,6 +1,6 @@ # Presentation Layer Templates -Ready-to-adapt code for a `{feature}_presentation` package. Extracted from `references/ffca_architecture.md`, sections *Presentation Layer* and *Routing & Navigation*. Each screen or independently loadable widget gets a `{screen}/bloc/`, `{screen}/views/`, and a `{screen}/{screen}_module.dart`. +Ready-to-adapt code for a `{feature}_presentation` package. Extracted from `references/ffca/presentation.md` and `references/ffca/navigation.md`. Each screen or independently loadable widget gets a `{screen}/bloc/`, `{screen}/views/`, and a `{screen}/{screen}_module.dart`. ## Cubit and sealed states (`{screen}/bloc/`) @@ -53,7 +53,7 @@ class CartBadgeCubit extends Cubit { ## Module (`{screen}/{screen}_module.dart`) -The feature's entry point. Declares all dependencies, wires the Provider and BlocProvider tree, and exposes navigation callbacks. Construct dependencies with Provider, get_it, riverpod, or prop drilling, as long as the dependencies are explicit. +The feature's entry point. Declares all dependencies, wires the Provider and BlocProvider tree, and exposes navigation callbacks. Construct dependencies with [`Provider`](https://pub.dev/packages/provider). Several packages could do the job; standardizing on one is the point, so that every module in every feature reads the same way. ```dart /// The module that loads the cart screen and dependencies @@ -116,28 +116,47 @@ InkWell( ) ``` -Alternative with Actions and Intents (no prop drilling, no Provider needed in deep widgets): +## Widget slots (visual extension points) + +The same inversion applied to widgets. Rather than importing another feature to display one of its widgets, the module reserves a slot and the app fills it. Name the slot for its position, never for the widget you expect, and default it to nothing so the feature stays runnable and golden-testable on its own. Stop at two slots per module; beyond that, the composition belongs in an app-owned shell. ```dart -return Actions( - actions: { - ProductTappedIntent: CallbackAction( - handler: (intent) => onProductTapped(intent.productId), - ), - }, - child: BlocProvider( - create: (_) => CartListCubit(...), - child: const CartListScreen(), - ), -); - -// Deep in the tree: -InkWell( - onTap: () => Actions.invoke(context, ProductTappedIntent(product.id)), - child: ProductCard(...), -) +// product_presentation: knows nothing about the cart feature. +class ProductDetailModule extends StatelessWidget { + const ProductDetailModule({ + required this.productId, + required this.productsRepository, + this.trailingAction, + super.key, + }); + + /// Filled by the app. Reserves a slot without knowing what goes in it. + final Widget? trailingAction; + + // ... +} ``` +```dart +// The app layer owns composition, so it is the app that imports cart_presentation. +class ProductDetailRoute extends GoRouteData with $ProductDetailRoute { + const ProductDetailRoute({required this.id}); + + final String id; + + @override + Widget build(BuildContext context, GoRouterState state) { + return ProductDetailModule( + productId: id, + productsRepository: context.read(), + trailingAction: CartBadge(cartsRepository: context.read()), + ); + } +} +``` + +Use a `WidgetBuilder` instead of a plain `Widget` only when construction has to wait until the slot is built, or when it needs the `BuildContext` available at fill time. + ## Routes (app layer, go_router_builder) `GoRouteData` classes live in the app layer and instantiate feature Modules with callback wiring. Navigate with generated route classes, never string paths. @@ -157,6 +176,52 @@ class CartListRoute extends GoRouteData { } ``` +### Splitting the routing table across files + +One routing file per feature, but they must all be `part` of a single library. `go_router_builder` collects `@TypedGoRoute` annotations per library, and routes declared in a separate library are silently dropped: the build succeeds and the routes do not exist. + +```text +apps/my_app/lib/app_router/ + routes.dart the library: imports, part directives, root route + favorites_routes.dart part + ideas_routes.dart part + routes.g.dart generated part +``` + +```dart +// routes.dart holds every import and every part directive. +library; + +import 'package:favorites_presentation/favorites_list.dart' + deferred as favorites_list; +import 'package:ideas_presentation/ideas_presentation.dart' deferred as ideas; + +part 'favorites_routes.dart'; +part 'ideas_routes.dart'; +part 'routes.g.dart'; +``` + +```dart +// favorites_routes.dart +part of 'routes.dart'; + +class FavoritesListRoute extends GoRouteData with $FavoritesListRoute { + const FavoritesListRoute(); + + @override + Widget build(BuildContext context, GoRouterState state) { + return favorites_list.FavoritesListModule( + favoritesRepository: context.read(), + // The feature reports that a row was tapped. The app decides that this + // means navigation. + onFavoriteTapped: (id) => FavoriteDetailsRoute(id: id).go(context), + ); + } +} +``` + +Adding a feature is two lines in `routes.dart`, its deferred import and its `part` directive, plus a new file nobody else is editing. Centralizing the imports is also what gives each subfeature barrel its `deferred as` prefix in one place. + ## Deep links and the $extra hydration pattern Screens must rebuild from URL parameters alone. `$extra` is a volatile optimization (lost on refresh, process death, cold-start deep links), so it is nullable and the Bloc treats it as optional. diff --git a/references/ffca/README.md b/references/ffca/README.md new file mode 100644 index 0000000..7b4d95f --- /dev/null +++ b/references/ffca/README.md @@ -0,0 +1,33 @@ +# FFCA Reference Mirror + +Do not edit these files by hand. They are byte copies of the canonical FFCA +documentation on [VGV Engineering][vge], regenerated by +`scripts/sync_reference.dart`. Any local edit is overwritten on the next sync +and reported as drift by CI. + +| File | Source | +| --- | --- | +| [`overview.md`](overview.md) | | +| [`domain.md`](domain.md) | | +| [`data.md`](data.md) | | +| [`presentation.md`](presentation.md) | | +| [`navigation.md`](navigation.md) | | +| [`project_structure.md`](project_structure.md) | | +| [`faq.md`](faq.md) | | + +To refresh the mirror: + +```sh +dart run scripts/sync_reference.dart +``` + +To verify it matches upstream without writing anything: + +```sh +dart run scripts/sync_reference.dart --check +``` + +Fix the architecture at the source. Changes belong on [VGV Engineering][vge], +and land here through a sync. + +[vge]: https://engineering.verygood.ventures/architecture/ffca/overview/ diff --git a/references/ffca/data.md b/references/ffca/data.md new file mode 100644 index 0000000..92b59e3 --- /dev/null +++ b/references/ffca/data.md @@ -0,0 +1,69 @@ +# Data Layer + +> Data sources, DTOs, and mapping them onto the domain models. + +- Source: https://engineering.verygood.ventures/architecture/ffca/data/ + +--- + +The data layer of a feature is responsible for providing a concrete implementation of the domain layer's repositories. How it does that is completely up to the data layer itself. It may use a [Drift](https://pub.dev/packages/drift) database for local storage and an http api as a remote source. It may introduce an in-memory cache if necessary. Those are implementation details handled by the data layer. + +## Data sources + +The different storage mechanisms are known as _data sources_. The repository is responsible for mixing these data sources to fulfil the contract, or interface, defined by the domain layer. + +For example, to enable some offline browsing, our online store app may have a Drift database data source and a Shopify http api data source. These live within the `data` package of a feature. + +The mealify feature-first app does exactly this. Its `meals_data` package holds two data sources side by side: a [Drift database](https://github.com/VGVentures/mealify_feature_first/tree/main/features/meals/meals_data/lib/src/data_sources/meals_database) for local storage, and an [http client](https://github.com/VGVentures/mealify_feature_first/tree/main/features/meals/meals_data/lib/src/data_sources/mealdb_api_client) for the remote API. Neither is visible outside the package: the [repository](https://github.com/VGVentures/mealify_feature_first/blob/main/features/meals/meals_data/lib/src/repositories/meals_repository.dart) combines them, and the domain layer only ever sees a `Meal`. + +## DTOs and mappers + +Each data source may return a DTO. For example, a `ProductDatabase` class might return a `DbProduct` generated by Drift. The `DbProduct` class knows a lot of information about the database, and is tightly coupled to Drift. We do not want to leak these classes to our domain layer, and eventually to our presentation layer. + +Therefore, the data layer is responsible for converting DTOs from each data source into the appropriate domain model, such as a `Product`. This can be achieved in a variety of ways: + +- Hand or AI-written extension methods, such as `dbProduct.toDomain()`. +- Hand or AI-written `Converter` classes, such as `class DbToDomainProduct extends Converter`. +- Libraries that generate the mapping for you using `build_runner`, such as [auto_mappr](https://pub.dev/packages/auto_mappr). + +A `Converter` keeps the mapping in one named, testable place, and it reads well at the call site: + +```dart +// product_data/lib/src/mappers/db_to_domain_product_converter.dart + +/// Converts the Drift row into the domain model. +class DbToDomainProductConverter extends Converter { + /// Construct a converter from database products to domain products. + const DbToDomainProductConverter(); + + @override + Product convert(DbProduct dbProduct) { + return Product( + id: dbProduct.id, + title: dbProduct.title, + description: dbProduct.description, + // The database stores cents as an integer. The domain works in whole + // currency units, so the conversion belongs here, not in a Bloc. + price: dbProduct.priceInCents / 100, + ); + } +} +``` + +The repository then applies it as data comes out of the source, so a `DbProduct` never escapes the package: + +```dart +// product_data/lib/src/repositories/products_repository.dart + +@override +Future getProductById(String id) async { + final dbProduct = await _database.getProduct(id); + return dbProduct == null ? null : _dbToDomainConverter.convert(dbProduct); +} +``` + +One converter per source and direction. A feature reading from both a database and an API has a `DbToDomain...` and an `ApiToDomain...`, which is what [meals_data](https://github.com/VGVentures/mealify_feature_first/tree/main/features/meals/meals_data/lib/src/mappers) does. + +For now, we do not believe it makes sense to define an abstract DTO for in-memory, local, and remote storage options. In general, it adds extra, unnecessary mapping to objects that are usually generated by Drift, Swagger, or other libraries. + +Next, the [presentation layer](/architecture/ffca/presentation/) builds the UI on top of the domain. diff --git a/references/ffca/domain.md b/references/ffca/domain.md new file mode 100644 index 0000000..0eeef48 --- /dev/null +++ b/references/ffca/domain.md @@ -0,0 +1,119 @@ +# Domain Layer + +> Models, Commands and Queries, repository interfaces, and combining one feature with another. + +- Source: https://engineering.verygood.ventures/architecture/ffca/domain/ + +--- + +The heart of the application. This layer is pure Dart, and entirely separate from "the outside world" of Flutter or concrete data sources, such as Firebase or http APIs. The domain layer defines the business models and operations of the given problem space, as well as the repositories required to perform those operations. + +## Models + +Models, sometimes called "entities," are objects that contain the information you are trying to represent in the problem space. For example, in the context of an online store app, the domain models might include a `Product` and a `Cart`. A `Product` class contains `id`, `title`, and `description` information. + +Do not add the word `Model` or `Entity` to this class. This is the core of the domain, and it should be easy to read and understand. + +## Business rules + +There are generally two types of rules in the domain space: commands and queries. _Commands_ make modifications to the domain models and often store them in a data source. _Queries_ retrieve domain models from a data source. Combined, these are often referred to as "use cases." + +For the purposes of our codebase, we recommend avoiding the term "use case" and instead prefer **Command** and **Query** classes for clarity. For example: + +- `UpdateProductTitleCommand`: updates the title of a product and stores it in the data source. +- `GetProductByIdQuery`: fetches product information based on the product id. It returns a `Future`. +- `WatchProductByIdQuery`: returns a new instance of a `Product` object any time the `Product` changes. It returns a `Stream`. + +If a Command or Query only serves to call out to the repository, there is no need to introduce the class at all. Most often, these classes are important when you need to combine several data sources together from different features through repositories, or if you find yourself performing the exact same work in several blocs. + +## Repositories + +You may need to load a `Product` from a local database or api. Therefore, the domain layer for the "Product feature" must define a way to fetch that information, without knowing how it is done. + +For this purpose, the domain defines repositories. In the domain layer, these classes are `abstract interface class` definitions. Repositories for each feature must be independent. The `Cart` domain should not define an `IProductsRepository` and vice-versa. This leads to coupling which makes refactors and data migrations difficult or impossible. An example of such a class might look like the following: + +```dart +abstract interface class IProductsRepository { + Future saveProduct(Product product); + Future getProductById(String productId); + Stream watchProductById(String productId); + Future updateProduct(Product product); + Future deleteProduct(String productId); +} +``` + +## Composing features + +In the domain layer, features often combine information and actions from other features. To solve this problem, feature domains may depend on other feature domains. For example, the Cart domain may rely on the Product domain, so that you can watch a `Cart` with populated `Product` information. + +There are two parts to consider here: how to make that data easy to read, and how to store the data. + +### Storing data + +To make it easy to work with `Cart` data, we want to store the `List` in the Cart. Therefore, a `Cart` object would look like this: + +```dart +class Cart { + Cart({required this.id, required this.products}); + + final String id; + final List products; +} +``` + +However, the Cart data layer should not know how to store or retrieve `Products`. Therefore, in order to populate the `List`, we must store it as a `List`, often times a UUID String, making it a `List`. To represent the class we want to store in our repository, we call this a **summary** object. For example, the `CartSummary` has the following shape: + +```dart +class CartSummary { + CartSummary({required this.id, required this.productIds}); + + final String id; + final List productIds; +} +``` + +The cart repository then reads and writes summaries, and only takes a full `Cart` when one is being created: + +```dart +abstract interface class ICartsRepository { + Future createCart(Cart cart); + Future getCartById(String cartId); + Stream watchCartById(String cartId); + Future addProductToCart(String cartId, String productId); + Future removeProductFromCart(String cartId, String productId); + Future deleteCart(String cartId); +} +``` + +### Reading the populated object + +Now, in order to read a populated cart, introduce a Query that combines the `ICartsRepository` and the `IProductsRepository`: + +```dart +class GetCartByIdQuery { + GetCartByIdQuery({ + required ICartsRepository cartsRepository, + required IProductsRepository productsRepository, + }) : _cartsRepository = cartsRepository, + _productsRepository = productsRepository; + + final ICartsRepository _cartsRepository; + final IProductsRepository _productsRepository; + + Future get(String cartId) async { + final cartSummary = await _cartsRepository.getCartById(cartId); + + return Cart( + id: cartSummary.id, + products: [ + for (final productId in cartSummary.productIds) + await _productsRepository.getProductById(productId), + ], + ); + } +} +``` + +Notice that the cart feature never learns how products are stored, and the product feature never learns that carts exist. + +Next, the [data layer](/architecture/ffca/data/) provides a concrete implementation of the repositories you just defined. diff --git a/references/ffca/faq.md b/references/ffca/faq.md new file mode 100644 index 0000000..a7bdb50 --- /dev/null +++ b/references/ffca/faq.md @@ -0,0 +1,205 @@ +# FFCA FAQ + +> Common questions about applying feature-first clean architecture. + +- Source: https://engineering.verygood.ventures/architecture/ffca/faq/ + +--- + +Most of FFCA follows from the [layer rules](/architecture/ffca/domain/). However, a few situations come up often enough, and have a non-obvious answer, that they're worth writing down. These are the questions we get asked the most. + +## Should we use callable classes for Commands and Queries? + +No. Callable classes look great at the call site. However, they break code navigation. For example, if you try to "find all usages" of a `call` method, Dart mixes different callable classes together. If you try to jump to the `call` method, you are unable to do so unless you explicitly use `call`. + +Therefore, please use the following verbs: + +- **Commands** use an `execute` method. +- **Queries** use a `get` or `watch` method, depending on whether it returns a `Future` or a `Stream`. + +## How do I handle auth and user profiles? + +Identity, meaning authentication, and entity, such as a user profile, are two separate domains. Therefore, each should be a separate feature. + +```mermaid +flowchart + subgraph auth_domain["auth_domain"] + IAuthRepository["IAuthRepository"] + AuthUser["AuthUser"] + end + + subgraph user_profile_domain["user_profile_domain"] + IUserProfilesRepository["IUserProfilesRepository"] + UserProfile["UserProfile"] + Query["WatchCurrentUserProfileQuery"] + end + + Query -->|"watchCurrentUser()"| IAuthRepository + Query -->|"watchUserProfile(authUser.id)"| IUserProfilesRepository + IAuthRepository -->|"emits"| AuthUser + IUserProfilesRepository -->|"emits"| UserProfile + Query -->|"returns"| UserProfile +``` + +If the `Auth` feature returns a full `UserProfile` object, with bio, address, settings, and so on, you couple your authentication logic to your business data. This means every time you add a field to the user profile, such as `themePreference`, you have to modify the `Auth` feature. That breaks the single responsibility principle and the bounded contexts of each feature. + +Therefore, it's recommended to split your `AuthUser` from a `UserProfile` object in different feature packages. + +**`auth_domain`:** + +```dart +class AuthUser { + const AuthUser({ + required this.id, + required this.email, + this.isEmailVerified = false, + }); + + final String id; + final String email; + final bool isEmailVerified; +} + +abstract interface class IAuthRepository { + Future getCurrentUser(); + Stream watchCurrentUser(); +} +``` + +Optionally, include minimal claims on `AuthUser` if your auth provider, such as Firebase or Auth0, gives them to you for free. Anything beyond that belongs in the profile. + +**`user_profile_domain`:** + +```dart +class UserProfile { + const UserProfile({ + required this.id, + required this.username, + required this.avatarUrl, + }); + + final String id; + final String username; + final String avatarUrl; +} + +abstract interface class IUserProfilesRepository { + Future getUserProfile(String userId); + Stream watchUserProfile(String userId); +} +``` + +In order to glue them together and return information about the current user, add a Query inside the `user_profile_domain`. This one uses `switchMap` from [rxdart](https://pub.dev/packages/rxdart): + +```dart +class WatchCurrentUserProfileQuery { + WatchCurrentUserProfileQuery({ + required IAuthRepository authRepository, + required IUserProfilesRepository userProfilesRepository, + }) : _authRepository = authRepository, + _userProfilesRepository = userProfilesRepository; + + final IAuthRepository _authRepository; + final IUserProfilesRepository _userProfilesRepository; + + Stream watch() { + return _authRepository.watchCurrentUser().switchMap((authUser) { + if (authUser == null) { + // Signed out, so there is no profile to watch. + return Stream.value(null); + } + + // Signed in, so switch to watching this user's profile. + return _userProfilesRepository.watchUserProfile(authUser.id); + }); + } +} +``` + +Notice that the `user_profile` feature depends on `auth_domain`, and `auth` knows nothing about profiles. + +## What if the backend has one large OpenAPI or Swagger definition for every endpoint? + +Generate a Dart package that knows how to talk to the Swagger client in the `shared` folder. As part of the package, it should start with a Dart script in the `tool` folder. The script should: + +1. Download the latest version of the OpenAPI or Swagger spec. +2. Generate the client with a package like [swagger_to_dart](https://pub.dev/packages/swagger_to_dart). It uses [Dio](https://pub.dev/packages/dio), so you get the same HTTP client as the `api_client` recipe in the [Very Good Flutter Cookbook](https://github.com/VGVentures/very_good_flutter_cookbook). +3. Export the parts each feature needs. + +Each feature then uses that shared client inside its own `data` package. Given a `/products/{id}` endpoint returning an `ApiProduct` DTO, `product_data` exposes a `ProductsRepository` with a `getProductById` method that: + +1. Checks whether the product is already in the local database. +2. If not, fetches it with the shared client. +3. Stores it in the database. +4. Maps the `ApiProduct` DTO to the domain `Product`. + +This way, the shared package stays generic, and every product-specific decision stays in the feature. + +### Dealing with nested objects + +Often times, APIs will also return nested objects. For example, you might have a `Photo` object with an embedded `User`. This is a classic "normalized cache vs. nested API response" problem. + +Say you want to fetch an `ApiPhoto` from the API, store the photo and the user in separate local tables, and return a `PhotoSummary` object which only contains the `userId`, not the complete `User` object. + +The responsibility of normalizing data, meaning the decision to store a `User` in one table and a `Photo` in another, belongs strictly to the data layer. The domain layer shouldn't know you are normalizing your cache. It just wants a `PhotoSummary`. Therefore, rather than creating a Query to handle this logic, keep it in the `PhotosRepository`. + +Here's how it works. `PhotosRepository`, in `photos_data`, depends on `IUsersRepository` from `users_domain`. This is a valid dependency, data to domain. When the API returns the nested JSON, the repository strips out the `User` data and sends it to the `IUsersRepository` before saving the `Photo`. + +```dart +class PhotosRepository implements IPhotosRepository { + PhotosRepository({ + required SwaggerRemoteDataSource remoteDataSource, + required PhotosLocalDataSource photosLocalDataSource, + required IUsersRepository usersRepository, + }) : _remoteDataSource = remoteDataSource, + _photosLocalDataSource = photosLocalDataSource, + _usersRepository = usersRepository; + + /// The generated Swagger client from the shared folder. + final SwaggerRemoteDataSource _remoteDataSource; + + /// The local data source, such as a Drift database. + final PhotosLocalDataSource _photosLocalDataSource; + + /// From the users_domain feature. + final IUsersRepository _usersRepository; + + @override + Future fetchPhotoById(String id) async { + // The DTO contains an embedded user. + final apiPhoto = await _remoteDataSource.getPhotoById(id); + + // This mapping lives in photos_data. Avoid importing it from users_data. + // That duplicates a little code, and it keeps photos_data decoupled from + // users_data. We trade DRY for bounded contexts here on purpose. + final user = apiPhoto.user.toDomain(); + await _usersRepository.saveUser(user); + + // Save the photo with only the userId, as a PhotoSummary. + final photoSummary = apiPhoto.toDomain(); + await _photosLocalDataSource.savePhotoSummary(photoSummary); + + return photoSummary; + } +} +``` + +```mermaid +sequenceDiagram + participant API as Remote Data Source + participant Repo as PhotosRepository
(photos_data) + participant UsersRepo as IUsersRepository
(users_domain) + participant LocalDB as PhotosLocalDataSource + + Note over Repo: fetchPhotoById(id) + Repo->>API: getPhotoById(id) + API-->>Repo: ApiPhoto { photo, user: ApiUser } + + Note over Repo: Strip and normalize nested data + Repo->>Repo: apiPhoto.user.toDomain() + Repo->>UsersRepo: saveUser(user) + + Repo->>Repo: apiPhoto.toDomain() + Repo->>LocalDB: savePhotoSummary(photoSummary) + Repo-->>Repo: return PhotoSummary +``` diff --git a/references/ffca/navigation.md b/references/ffca/navigation.md new file mode 100644 index 0000000..bda7fa2 --- /dev/null +++ b/references/ffca/navigation.md @@ -0,0 +1,276 @@ +# Navigation + +> How strictly isolated features navigate without importing each other's routes. + +- Source: https://engineering.verygood.ventures/architecture/ffca/navigation/ + +--- + +Since features are strictly isolated, they cannot know about the application's global routing table. A feature, such as `Cart`, cannot directly import the routes of another feature, such as `Product`. This would create circular dependencies and tight coupling. + +Instead, we treat navigation as a dependency. + +The app layer, acting as the "assembler," is responsible for defining _what happens next_. The feature layer is responsible for detecting _when_ that action is needed. We achieve this by injecting navigation callbacks into the feature module. + +## Simple features: pass callbacks directly + +For features with shallow widget trees, pass the callbacks directly into the module's constructor, passing them further down the widget tree where they are needed. + +```dart +// The app layer assembles the feature. Repository arguments are omitted here +// to keep the focus on navigation. +CartListModule( + id: cartId, + // The app decides that tapping a product navigates to the product route. + onProductTapped: (id) => router.push('/product/$id'), + onCheckoutStarted: () => router.push('/checkout'), +); +``` + +```mermaid +sequenceDiagram + participant App as App Layer + participant Module as Feature Module + + App->>Module: CartListModule(
onProductTapped: (id) => router.push('/product/id'),
) + Note over App,Module: App decides what happens next.
Feature only decides when. +``` + +## Deep trees: define a navigation interface + +For features with deep widget trees, passing callbacks down multiple constructors is tedious. Instead, define a **navigation interface** for your feature. + +First, create a pure Dart class in your feature's presentation layer that defines all possible navigation actions: + +```dart +class CartNavigation { + CartNavigation({ + required this.onProductTapped, + required this.onCheckoutStarted, + }); + + final void Function(String productId) onProductTapped; + final VoidCallback onCheckoutStarted; +} +``` + +Next, require these actions in your feature module and provide them to the subtree: + +```dart +class CartListModule extends StatelessWidget { + // This variant replaces onProductTapped and onCheckoutStarted with a single + // CartNavigation. The id and repository arguments are unchanged, and are + // omitted here to keep the example short. + const CartListModule({ + required this.cartNavigation, + super.key, + }); + + final CartNavigation cartNavigation; + + @override + Widget build(BuildContext context) { + return Provider.value( + // Make navigation available to every child widget. + value: cartNavigation, + child: CartListScreen(id: id), + ); + } +} +``` + +Now any button, anywhere in the feature, can navigate without prop drilling: + +```dart +InkWell( + onTap: () => context.read().onProductTapped(product.id), + child: ProductCard(product: product), +) +``` + +```mermaid +sequenceDiagram + actor User + participant App as App Layer + participant Module as Feature Module
(cart_presentation) + participant Widget as Nested Widget + participant Nav as CartNavigation + + App->>Module: CartListModule(cartNavigation: CartNavigation(...)) + Module->>Widget: Provider.value(value: cartNavigation) + Note over Module,Widget: Navigation available
to entire subtree + + User->>Widget: taps product card + Widget->>Nav: context.read CartNavigation
.onProductTapped(product.id) + Nav-->>App: onProductTapped('abc-123') + App->>App: router.push('/product/abc-123') +``` + +## Recommended: go_router with go_router_builder + +Use [go_router](https://pub.dev/packages/go_router) with [go_router_builder](https://pub.dev/packages/go_router_builder) for type-safe route classes. The app layer defines `GoRouteData` classes that instantiate feature modules with the callback wiring: + +```dart +@TypedGoRoute(path: '/cart/:id') +class CartListRoute extends GoRouteData { + const CartListRoute({required this.id}); + + final String id; + + @override + Widget build(BuildContext context, GoRouterState state) { + return CartListModule( + id: id, + cartsRepository: context.read(), + productsRepository: context.read(), + onProductTapped: (productId) => ProductDetailRoute(id: productId).go(context), + onCheckoutStarted: () => CheckoutRoute().go(context), + ); + } +} +``` + +Two routing constraints hold this together: + +- **Never** import the app's router configuration or another feature's routes from inside a feature. +- **Always** navigate through generated type-safe route classes rather than string paths. + +Bad ❗️: +```dart +// The path is a string, so a renamed route fails at runtime, not at +// compile time. The feature also has to know the app's URL structure. +context.push('/product/123'); +``` + +Good ✅: +```dart +ProductDetailRoute(id: '123').go(context); +``` + +For general routing guidance beyond FFCA, see [navigation](/development/ui/navigation/). + +### Splitting the routing table across files + +The app owns the routing table, so every feature you add lands in the same file. On a team of any size that file turns into a merge-conflict magnet, and it gets long. Therefore, give each feature's routes a file of their own. + +There is one constraint on how you split it. `go_router_builder` collects every `@TypedGoRoute` it finds in a _library_ into a single generated `$appRoutes`. Routes declared in a separate library are left out of it, and the failure is quiet: the build succeeds and the routes simply do not exist. So the files have to be `part` of one library rather than separate imports. + +- apps/my_app/lib/app_router/ + - routes.dart the library: imports, part directives, root route + - favorites_routes.dart part + - ideas_routes.dart part + - routes.g.dart generated part + +`routes.dart` holds the imports, the `part` directives, and the root route: + +```dart +/// The app's routing table. +/// +/// Every feature is imported with a `deferred as` prefix, so its code is +/// fetched the first time a route needs it instead of shipping in the initial +/// bundle. That is what splits the web build into per-screen chunks and what +/// allows Android dynamic feature modules. +/// +/// Where a feature exposes subfeature barrels, the import names one screen +/// rather than the whole package, so opening the favorites list does not also +/// download the details screen. +library; + +import 'package:favorites_presentation/favorites_list.dart' + deferred as favorites_list; +import 'package:ideas_presentation/ideas_presentation.dart' deferred as ideas; + +// The branches live in their own files for readability, but they must stay +// part of this library, or go_router_builder will not collect them. +part 'favorites_routes.dart'; +part 'ideas_routes.dart'; +part 'routes.g.dart'; + +@TypedStatefulShellRoute( + branches: [ideasBranch, favoritesBranch], +) +class AppShellRouteData extends StatefulShellRouteData { + const AppShellRouteData(); + + @override + Widget builder( + BuildContext context, + GoRouterState state, + StatefulNavigationShell navigationShell, + ) { + return ResponsiveScaffold(navigationShell: navigationShell); + } +} +``` + +Each feature's branch then lives in its own part file, holding the route classes that wire its modules: + +```dart +part of 'routes.dart'; + +const favoritesBranch = TypedStatefulShellBranch( + routes: [ + TypedGoRoute( + path: '/favorites', + routes: [ + TypedGoRoute(path: ':id'), + ], + ), + ], +); + +class FavoritesBranch extends StatefulShellBranchData { + const FavoritesBranch(); +} + +class FavoritesListRoute extends GoRouteData with $FavoritesListRoute { + const FavoritesListRoute(); + + @override + Widget build(BuildContext context, GoRouterState state) { + return favorites_list.FavoritesListModule( + favoritesRepository: context.read(), + // The feature reports that a row was tapped. The app decides that this + // means navigation, which is what lets favorites_presentation stay + // unaware of the routing table. + onFavoriteTapped: (id) => FavoriteDetailsRoute(id: id).go(context), + ); + } +} +``` + +Because part files share the library's imports, every import stays in `routes.dart`. Adding a feature means two lines there, its deferred import and its `part` directive, and then a new file nobody else is editing. Those two lines are the only shared surface left, so conflicts are rare and trivial to resolve when they do happen. + +Centralizing the imports pays off a second time, because it is also where the [subfeature barrels](/architecture/ffca/presentation/#subfeature-barrel-files) get their `deferred as` prefixes. You can see the whole thing wired up in the [`app_router` of the mealify feature-first app](https://github.com/VGVentures/mealify_feature_first/tree/main/apps/mealify_app/lib/app_router). + +## Deep links and data hydration + +Screens must always be rebuildable from URL parameters alone. When navigating within the app, an `$extra` object can be passed to skip a loading state. However, `$extra` is volatile, and is lost on browser refresh, process death, or cold-start deep links. Therefore, every screen must handle it being null: + +```dart +@TypedGoRoute(path: '/product/:id') +class ProductDetailRoute extends GoRouteData { + const ProductDetailRoute({required this.id, this.$extra}); + + final String id; + + /// A performance optimization. May be null on a cold start or refresh. + final Product? $extra; + + @override + Widget build(BuildContext context, GoRouterState state) { + return ProductDetailModule( + productId: id, + initialProduct: $extra, + ); + } +} +``` + +The bloc checks if `initialProduct` is available. If so, it emits `Loaded` immediately. If not, it fetches by `productId` and shows a loading state. + +Since a module is a self-contained entry point with explicit dependencies, you can also embed it in a native host app. See [add-to-app support](/architecture/ffca/project_structure/#add-to-app-support). + +## Visual extension points + +The same inversion applies to widgets. A feature can declare a slot for a widget it doesn't own and let the app fill it, in the same way it declares a callback for a destination it doesn't own. See [widgets that own state](/architecture/ffca/presentation/#widgets-that-own-state). diff --git a/references/ffca/overview.md b/references/ffca/overview.md new file mode 100644 index 0000000..3db0332 --- /dev/null +++ b/references/ffca/overview.md @@ -0,0 +1,171 @@ +# Feature-First Clean Architecture + +> An architecture for Flutter monorepos where features are packages that apps compose. + +- Source: https://engineering.verygood.ventures/architecture/ffca/overview/ + +--- + +Feature-First Clean Architecture (FFCA) is our AI-ready monorepo architecture for Flutter. Every feature is a small set of Dart and Flutter packages, and your apps compose those features into something you can ship. + +Why go to the trouble of making each layer a separate package, rather than a folder? Because a package boundary is enforced for you. If the cart feature isn't allowed to reach into the product feature's data layer, then that package isn't in its `pubspec.yaml`, and the analyzer will say so before a reviewer has to. Furthermore, well-bounded packages give AI agents a deterministic target to write into, which reduces errors and makes their output consistent regardless of the model or the prompt. + +## Goals + +FFCA is designed to: + +- Give AI agents deterministic targets, so their output stays consistent regardless of model or prompt. +- Enable teams to ship features independently, without waiting on other teams. +- Establish ownership models for shared code. +- Allow features to be reused in different apps. For example, you could build a feature and use it for a _point of sale_ app, a _mobile_ app, and an _admin_ app. +- Reduce friction when multiple teams touch the same codebase. +- Address a couple of shortcomings we've hit with our other architectural approaches, namely multiple blocs needing to perform the same data transformations, and the difficulty of sharing functionality across different blocs. + +:::tip[Which architecture should you reach for?] +This architecture is designed for Flutter projects using AI-assisted development, monorepos with multiple apps sharing features, teams organized around feature ownership, projects where features need to be added or removed per app, and codebases that need to scale predictably. + +Consider our standard [layered architecture](/architecture/architecture/) when you have a single app with fewer than three features, when you're not using AI-assisted development and the package overhead isn't justified, or when features will never be shared across apps or teams. + +That said, if you're using AI tools, the package overhead is near-zero, because the AI generates it. The structural boundaries also improve AI output quality. Therefore, consider starting with FFCA even for smaller projects. +::: + +## The structure + +Each project contains three major parts in a monorepo workspace: **apps**, **features**, and **shared libraries**. Apps depend on features and shared libraries, features depend on shared libraries, and nothing depends on an app. + +```mermaid +--- +config: + layout: elk + themeVariables: + fontSize: 20px +--- +flowchart TB + subgraph apps["Apps"] + kiosk["kiosk_app"] + mobile["mobile_app"] + admin["admin_app"] + end + subgraph features["Features"] + subgraph product["product"] + product_domain["product_domain
(Dart)"] + product_data["product_data
(Dart)"] + product_presentation["product_presentation
(Flutter)"] + end + subgraph cart["cart"] + cart_domain["cart_domain
(Dart)"] + cart_data["cart_data
(Dart)"] + cart_presentation["cart_presentation
(Flutter)"] + end + subgraph auth["auth (headless)"] + auth_domain["auth_domain
(Dart)"] + auth_data["auth_data_firebase
(Dart)"] + end + subgraph analytics["analytics (headless)"] + analytics_domain["analytics_domain
(Dart)"] + analytics_data["analytics_data_posthog
(Dart)"] + end + end + subgraph shared["Shared"] + api_client["api_client
(Dart)"] + ui_kit["ui_kit
(Flutter)"] + localizations["localizations
(Flutter)"] + logging["logging
(headless feature)"] + end + apps --> features + apps --> shared + product_data --> product_domain + product_presentation --> product_domain + cart_data --> cart_domain + cart_presentation --> cart_domain + cart_domain --> product_domain + auth_data --> auth_domain + analytics_data --> analytics_domain + features --> shared +``` + +### Apps + +Apps are the deployable applications. In a large codebase, you might have several of them, such as a _kiosk_ app, an _admin_ app, and a _mobile_ app. + +In this architecture, the responsibility of the app is rather limited. It composes a series of features and shared libraries to build a complete, deployable artifact that your target audience can use. Each app then defines its own routing structure, environment configurations, bundle ids, and CI/CD workflows for verification and deployment. + +### Features + +A feature is a cohesive unit of functionality. Some features have screens, some don't. Every feature follows clean architecture, with domain, data, and optionally presentation layers as separate packages. + +A feature with all three layers owns a user-facing flow. For example, an online store app may have a `Product` or `Cart` feature, to display a Product Screen or Shopping Cart. + +#### Headless features + +A feature without a presentation layer is a **headless feature**. It provides business logic and data access consumed by other features, but it owns no screens. Common examples include `auth`, `analytics`, and `user_profile`. + +A headless feature can grow into a full feature by adding a presentation layer package. For example, `auth` starts as a headless feature with domain and data, and when you need a login screen, adding `auth_presentation` makes it a full feature. No structural changes to the existing packages are required. + +#### Presentation-only features + +A **presentation-only feature** has a presentation package and no domain or data package. It exists to compose other features' domains into a screen. + +Where a headless feature has domain and data and no UI, a presentation-only feature is the opposite arrangement. All UI, and no domain of its own. + +The `ideas` feature in the [mealify reference implementation](https://github.com/VGVentures/mealify_feature_first) is one. `ideas_presentation` depends on `drinks_domain`, `favorites_domain`, and `meals_domain`, and there is no `ideas_domain`. + +The layout and naming don't change. It sits at `features/{name}/{name}_presentation` like any other presentation package, and the absence of siblings is the signal. Nothing needs declaring. + +### Shared libraries + +Applications and features may require common, shared libraries to achieve their goals. For example, many features may need a Swagger-generated `api_client` to perform HTTP requests, or a `ui_kit` (aka design system) for common widgets. Rather than each feature defining their own `api_client`, we can use a shared library across all features. + +The distinction between shared libraries and features can be a bit fuzzy. A good rule of thumb: shared code should have zero knowledge of your app's features. Shared libraries never import from a feature folder, and their API makes sense without any feature-specific context. If you find yourself referencing a particular feature from shared, that code likely belongs in the feature instead. + +The decision rule comes down to two questions: + +- **Is it a business capability that apps compose?** Then it belongs in `features/`. +- **Could you publish it to [pub.dev](https://pub.dev) and would a stranger use it unchanged?** Then it belongs in `shared/`. + +This is why business-domain packages like `auth`, `analytics`, and `user_profile` are features, often headless ones, rather than shared libraries. They represent business decisions: which auth provider to use, which analytics events to track, and what user data to store. Different apps have different backends. For example, some apps may use Firebase Auth while others use Auth0, and some apps may use Google Analytics while others use PostHog. + +Infrastructure packages like `logging` and `secure_storage` can be either shared libraries, if they're generic enough for any project, or headless features, if they're customized for the project. A generic `ILogger` interface with a Sentry implementation belongs in `shared/`. A project-specific analytics setup with custom events belongs in `features/`. + +| Component | Shared? | Why | +| ------------------ | ------- | ----------------------------------------------------------------------- | +| `PrimaryButton` | Yes | It is part of the brand design system, and agnostic of what it clicks. | +| `LoginButton` | No | It implies a "Login" domain concept. Put it in `auth_presentation`. | +| `DioClient` | Yes | It wraps generic HTTP logic, such as interceptors and timeouts. | +| `ProductApiClient` | No | It knows about "Products" and API endpoints. Put it in `product_data`. | +| `formatCurrency()` | Yes | Formats a double to a string. Generic. | +| `calculateTax()` | No | Tax rules are volatile business logic. Put it in `cart_domain`. | + +## Feature layers + +Each feature is made up of three layers: the _domain_ layer, the _data_ layer, and the _presentation_ layer, following the practices of [clean architecture](https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html). Each of these layers is an individual Dart or Flutter package. + +- The **[domain layer](/architecture/ffca/domain/)** is the heart of the feature. It is pure Dart, and it defines your models, the business rules that operate on them, and the repository interfaces the feature needs. +- The **[data layer](/architecture/ffca/data/)** implements those repository interfaces. It owns the databases, the http clients, and the mapping from their DTOs onto your domain models. +- The **[presentation layer](/architecture/ffca/presentation/)** builds the UI, and defines the blocs that tie the domain layer to your widgets. A [headless feature](#headless-features) simply doesn't have one. + +Notice which way the arrows point. Both the data layer and the presentation layer depend on the domain, and the domain depends on neither of them. Therefore, you can swap Firebase for Auth0, or rebuild a screen from scratch, without touching the rules in the middle. + +```mermaid +flowchart TB + subgraph Feature + direction TB + P["Presentation Layer
(Flutter package)"] + D["Domain Layer
(Dart package)"] + DA["Data Layer
(Dart package)"] + + P -->|"depends on"| D + DA -->|"depends on"| D + end +``` + +## Where to go next + +Now that you know how a project is laid out, here's the rest of the section, in the order the layers depend on each other: + +- [Domain](/architecture/ffca/domain/) covers models, Commands and Queries, repository interfaces, and how one feature combines information from another. +- [Data](/architecture/ffca/data/) covers data sources, DTOs, and mapping them onto your domain models. +- [Presentation](/architecture/ffca/presentation/) covers modules, localizations, and subfeature barrel files. +- [Navigation](/architecture/ffca/navigation/) covers how a feature moves the user elsewhere without importing another feature's routes. +- [Project structure](/architecture/ffca/project_structure/) covers naming conventions, the folder layout, and monorepo tooling. +- [FAQ](/architecture/ffca/faq/) answers the questions that come up most often. diff --git a/references/ffca/presentation.md b/references/ffca/presentation.md new file mode 100644 index 0000000..6b2a061 --- /dev/null +++ b/references/ffca/presentation.md @@ -0,0 +1,206 @@ +# Presentation Layer + +> Modules, localizations, and subfeature barrel files in a feature's presentation package. + +- Source: https://engineering.verygood.ventures/architecture/ffca/presentation/ + +--- + +The presentation layer of each feature is responsible for defining the user interface and glueing everything together to display that UI. Generally, that will be a series of Flutter widgets. However, since the domain and data layers are headless, you could adapt it for a CLI app as well. + +In the context of a Flutter application, the presentation layer will: + +- Define an entry point for the feature, or parts of the feature. +- Define the various screens and widgets needed to implement a feature. +- Define the blocs that tie your domain layer to your widgets. + +## The module + +Each feature, or part of a feature, should be defined by a **module**. If a feature has many screens, or complex subcomponents, each one can define its own module. A module is responsible for defining and setting up the dependencies required by a feature, or part of a feature. This achieves three goals: + +1. Each module clearly defines all of its dependencies. No surprises. +2. It moves registration of dependency injection for that part of a feature to a common location. +3. It enables the use of [deferred imports](/architecture/ffca/project_structure/#deferred-loading), which are important for splitting web bundles into smaller parts and enabling Android dynamic modules. + +Construct the dependencies with [`Provider`](https://pub.dev/packages/provider). Several packages can achieve the goals above, and we standardize on one so that every module in every feature reads the same way. A module looks like this: + +```dart +/// The module that loads the cart screen and its dependencies. +class CartListModule extends StatelessWidget { + /// Constructs the module that loads a user's cart. + const CartListModule({ + required this.id, + required this.cartsRepository, + required this.productsRepository, + required this.onProductTapped, + required this.onCheckoutStarted, + super.key, + }); + + /// The repository for carts. + final ICartsRepository cartsRepository; + + /// The repository for products. + final IProductsRepository productsRepository; + + /// The id of the cart to load. + final String id; + + /// Called when the user taps a product. The app decides where that goes. + final void Function(String productId) onProductTapped; + + /// Called when the user starts checkout. + final VoidCallback onCheckoutStarted; + + @override + Widget build(BuildContext context) { + // The carts and products repositories are used on many screens, so assume + // they have been constructed in a Provider at a level above. + return Provider( + create: (context) => GetCartByIdQuery( + cartsRepository: cartsRepository, + productsRepository: productsRepository, + ), + child: BlocProvider( + create: (context) { + return CartListCubit( + getCartByIdQuery: context.read(), + cartsRepository: cartsRepository, + ); + }, + child: CartListScreen(id: id), + ), + ); + } +} +``` + +## Localizations + +Localizations can be either shared or per-feature. There are pros and cons to both approaches. To keep it simple, start with a Flutter package in the `shared` folder, and use it amongst all of your features in the presentation layer. + +If your app is more complex and very large, it may be worthwhile for each feature to define their own localizations. However, this introduces additional complexity. + +## Widgets that own state + +A widget that owns a `Cubit` is not a separate feature. It lives in its own feature's presentation package, and it is exported through a barrel file. + +`CartBadge` depends on `cart_domain`, its own feature's domain. What makes it worth discussing is that it renders inside a screen owned by a different feature. The question is not whether a widget may reach across domains. It is who places it, and what the dependency costs. + +```dart +// cart_presentation/lib/cart_badge/bloc/cart_badge_cubit.dart + +class CartBadgeCubit extends Cubit { + CartBadgeCubit({required ICartsRepository cartsRepository}) + : _cartsRepository = cartsRepository, + super(const CartBadgeInitial()); + + final ICartsRepository _cartsRepository; + + Future loadItemCount(String cartId) async { + emit(const CartBadgeLoading()); + try { + final cartSummary = await _cartsRepository.getCartById(cartId); + emit(CartBadgeLoaded(itemCount: cartSummary.productIds.length)); + } catch (error, stackTrace) { + addError(error, stackTrace); + emit(CartBadgeError(message: error.toString())); + } + } +} +``` + +### Sharing a widget across features + +A presentation package may depend on another feature's presentation package and use a public widget from it. Three conditions apply. + +**The widget gets its own barrel.** Consumers import `package:cart_presentation/cart_badge.dart`, never the primary `package:cart_presentation/cart_presentation.dart` barrel. + +**The barrel stays narrow.** Its transitive imports must not reach the feature's modules or screens. A widget barrel that pulls in `cart_domain` and the design system costs a consumer a widget. One that goes through the primary barrel costs them every screen and `Cubit` the cart feature owns, and it breaks [deferred loading](/architecture/ffca/project_structure/#deferred-loading) downstream. + +**No cycles.** If two features each need a widget from the other, they share something that belongs underneath both of them rather than beside either. Extract it downward instead of importing sideways. + +### Or let the app place it + +The alternative is that the consuming feature never learns the other feature exists. It declares a slot, and the app fills it. This is the same inversion we use for [navigation](/architecture/ffca/navigation/). The feature declares the extension point, and the app supplies the implementation. + +```dart +class ProductDetailModule extends StatelessWidget { + const ProductDetailModule({ + required this.productId, + required this.productsRepository, + this.trailingAction, + super.key, + }); + + /// Filled by the app. Reserves a slot without knowing what goes in it. + final Widget? trailingAction; + // ... +} +``` + +```dart +class ProductDetailRoute extends GoRouteData with $ProductDetailRoute { + const ProductDetailRoute({required this.id}); + + final String id; + + @override + Widget build(BuildContext context, GoRouterState state) { + return ProductDetailModule( + productId: id, + productsRepository: context.read(), + // The app owns composition, so cart_presentation is imported here + // rather than by product_presentation. + trailingAction: CartBadge(cartsRepository: context.read()), + ); + } +} +``` + +A plain `Widget` parameter is enough in most cases. Reach for a `WidgetBuilder` when construction has to wait until the slot is built, or when it needs the `BuildContext` available at fill time. + +Name the slot for its position rather than for the widget you expect to fill it. `trailingAction` keeps the API honest, and `cartBadgeBuilder` leaks the other feature straight back in. + +Default the slot to nothing rather than making it required, so the feature stays runnable and golden-testable without the other feature's repositories in the widget tree. + +More than two slots on one module is a sign the composition belongs one level up, in a shell owned by the app. + +### Choosing between them + +Use a slot when the widget is app chrome. A cart badge in an app bar belongs to the shell rather than to the product feature, and in a shell layout the app builds it with a repository already in scope. + +Use a direct import behind a narrow barrel when the widget is genuinely part of what the consuming screen _is_, rather than something placed around it. + +### Where the widget should live + +When the same widget is wanted in more than one feature, work through this in order. + +1. **Does it need a repository or a domain type?** If not, it is a pure presentational component, and it belongs in `ui_kit`. This test is mechanical rather than stylistic. [Shared packages depend on external packages only](/architecture/ffca/project_structure/#dependency-rules), so a widget that needs a repository cannot compile there. +2. **If it does, split it.** `CartBadgeView`, taking a plain count, goes in `ui_kit`. `CartBadge`, the `Cubit` plus that view, stays in `cart_presentation` behind its own barrel. In practice, a widget that gets genuinely reused across features is usually a presentational leaf with the stateful part left behind, which means the reuse pressure was pointing at `ui_kit` all along. + +It becomes its own feature when it grows its own business logic, and a [presentation-only feature](/architecture/ffca/overview/#presentation-only-features) when it composes several features without owning business logic of its own. + +## Subfeature barrel files + +A single feature may expose multiple independent entry points. For example, `favorites_presentation` might have a list screen and a detail screen that apps import separately. Each subfeature gets its own barrel file, and the primary barrel re-exports everything: + +- features/favorites/ + - favorites_presentation/ + - lib/ + - favorites_list/ + - favorites_detail/ + - favorites_list.dart (subfeature barrel) + - favorites_detail.dart (subfeature barrel) + - favorites_presentation.dart (primary barrel, re-exports all) + +This is essential for **deferred imports**. An app that wants to lazy-load the favorites detail screen imports only its subfeature barrel with a deferred prefix: + +```dart +import 'package:favorites_presentation/favorites_detail.dart' + deferred as favorites_detail; +``` + +With a single barrel file, you can't defer-load part of a package, because importing anything pulls in everything. Subfeature barrels enable fine-grained code splitting for web bundles and Android dynamic modules. See [deferred loading](/architecture/ffca/project_structure/#deferred-loading) for the constraint this places on cross-feature imports, and for when it gains you nothing. For more on barrel files generally, see [barrel files](/architecture/barrel_files/). + +Next, [navigation](/architecture/ffca/navigation/) covers how a module moves the user to another feature without importing it. diff --git a/references/ffca/project_structure.md b/references/ffca/project_structure.md new file mode 100644 index 0000000..8b920ce --- /dev/null +++ b/references/ffca/project_structure.md @@ -0,0 +1,239 @@ +# Project Structure + +> Dependency rules, naming conventions, folder layout, and monorepo tooling for an FFCA project. + +- Source: https://engineering.verygood.ventures/architecture/ffca/project_structure/ + +--- + +FFCA leans heavily on convention. Package names, folder names, and the direction dependencies are allowed to flow are all fixed. Consistent structure like this enables tooling inference, AI comprehension, and mechanical validation, so you can check the shape of a project with a script rather than in code review. + +## Dependency rules + +Dependencies flow in one direction only. Apps depend on features and shared packages, features depend on shared packages, and nothing depends on an app. Within a feature, the domain layer sits at the bottom. The data layer imports it to implement the repository interfaces, the presentation layer imports it to render and to call into it, and the domain imports neither of them. That is what lets you swap a backend or rebuild a screen without touching the business rules. + +This table is the whole rule, and it is the version a validation script implements: + +| Package | May depend on | +| ------------------------ | ---------------------------------------------------------------------------------------------- | +| `{name}_app` | any feature package, any shared package | +| `{feature}_domain` | shared packages, other features' `_domain` | +| `{feature}_data` | its own `_domain`, other features' `_domain`, shared packages | +| `{feature}_presentation` | its own `_domain`, other features' `_domain`, other features' `_presentation`, shared packages | +| `shared` | external packages only | + +Two absences from that table carry as much weight as the rows themselves: + +- **No presentation package depends on any `_data` package**, its own included. The app wires the data layer implementation in. +- **No shared package depends on a feature package.** A shared package depends on external packages only, which is why a widget that needs a repository cannot live in `ui_kit`. + +Cycles between packages are forbidden at every layer. If two packages each need something from the other, that shared part belongs in a third package underneath both of them. + +A dependency on another feature's presentation package carries two extra conditions, covered in [widgets that own state](/architecture/ffca/presentation/#widgets-that-own-state). It has to target a dedicated barrel, and the graph has to stay acyclic. That second condition is also what protects [deferred loading](#deferred-loading). + +```mermaid +flowchart TB + subgraph Domain + Models["Models"] + RepoInterface["Repository Interface"] + UseCases["Commands and Queries (optional)"] + UseCases -->|"maps to"| Models + UseCases --> RepoInterface + end + + subgraph Data + DTO["DTO"] + DataSources["Data Sources"] + RepoImpl["Repository Impl"] + Mappers["Mappers"] + RepoImpl --> DataSources + RepoImpl --> Mappers + DataSources -->|"uses"| DTO + end + + subgraph Presentation + BLoC["Bloc / Cubit"] + Widget["Widget / View"] + Widget --> BLoC + end + + Presentation --> Domain + BLoC --> UseCases + RepoImpl -.->|"implements"| RepoInterface + RepoInterface -->|"uses"| Models + Mappers <-->|"maps to/from"| Models + Mappers --> DTO +``` + +## Deferred loading + +Deferred imports let an app load part of its code on demand rather than at startup. FFCA's package boundaries are what make this possible. The router imports each feature behind a `deferred as` prefix, and the compiler emits a separate chunk per feature. + +This works on web builds today, and it backs Android [deferred components](https://docs.flutter.dev/perf/deferred-components). On an app that ships only to iOS and Android, the AOT snapshot contains the whole program regardless, so deferred loading gains you nothing, and this section is informational. + +### The constraint + +A package the app loads deferred must not also be reachable from the app through a non-deferred import path. If `product_presentation` imports `cart_presentation` eagerly, then deferring `cart` from the router achieves nothing, because `cart` is already in `product`'s chunk. + +:::caution +This failure is silent. Nothing breaks, no analyzer warning fires, and the bundle stops splitting where you expected it to. That is why we state it as a rule rather than leave it to code review. +::: + +### Enforcing it + +Mark the deferred edges in the app's import graph, compute the set of packages reachable without crossing one, and assert that no deferred target appears in it. + +Deferral is per-library rather than per-package, so a check that only parses `pubspec.yaml` catches the coarse case. Catching it at [subfeature barrel](/architecture/ffca/presentation/#subfeature-barrel-files) granularity needs the library-level import graph. + +## Naming conventions + +The architecture enforces naming conventions for packages. This is how tooling and AI agents can work out what a package does without opening it. + +| Package | Convention | Example | +| ------------------------- | ------------------------ | -------------------- | +| Feature domain | `{feature}_domain` | `cart_domain` | +| Feature data | `{feature}_data` | `cart_data` | +| Feature presentation | `{feature}_presentation` | `cart_presentation` | +| Data with specific backend | `{feature}_data_{backend}` | `auth_data_firebase` | +| Shared package | descriptive name | `ui_kit`, `api_client` | + +Data packages with a backend suffix, such as `auth_data_firebase`, signal the backing implementation. Swapping to `auth_data_auth0` requires no structural changes anywhere else, because everything upstream depends on `auth_domain`. + +## Folder layout + +- apps/ + - kiosk_app/ Flutter package + - mobile_app/ Flutter package + - admin_app/ Flutter package +- features/ + - product/ + - product_domain/ Dart package + - lib/ + - models/ + - product.dart + - repositories/ + - i_products_repository.dart + - product_domain.dart barrel file + - product_data/ Dart package + - lib/ + - data_sources/ + - products_remote_data_source/ + - dtos/ + - product_dto.dart + - products_remote_data_source.dart + - mappers/ + - product_mapper.dart + - repositories/ + - products_repository.dart + - product_data.dart barrel file + - product_presentation/ Flutter package + - lib/ + - product_detail/ + - bloc/ + - views/ + - product_detail_module.dart + - product_list/ + - bloc/ + - views/ + - product_list_module.dart + - product_detail.dart subfeature barrel + - product_list.dart subfeature barrel + - product_presentation.dart primary barrel + - cart/ depends on product_domain + - cart_domain/ + - cart_data/ + - cart_presentation/ + - auth/ headless feature, no presentation + - auth_domain/ + - auth_data_firebase/ + - analytics/ headless feature + - analytics_domain/ + - analytics_data_posthog/ + - user_profile/ headless feature + - user_profile_domain/ + - user_profile_data/ +- shared/ + - api_client/ + - ui_kit/ + - localizations/ + - logging/ headless feature, generic enough to share + - logging_domain/ + - logging_data_sentry/ + +### Layer subfolders + +Each layer uses the same subfolders every time, so you always know where to look for something. + +**Domain** (`{feature}_domain/lib/`): + +| Folder | Contents | +| --------------- | ----------------------------------------------------------------------------- | +| `models/` | Domain models, as pure Dart classes with value equality | +| `repositories/` | Repository interfaces, declared as `abstract interface class` | +| `use_cases/` | Command and Query classes. Only needed when combining multiple repositories | + +One note on naming: the folder keeps the conventional `use_cases/` name so the layout matches other clean architecture projects, even though we [name the classes themselves](/architecture/ffca/domain/#business-rules) Command and Query. + +**Data** (`{feature}_data/lib/`): + +| Folder | Contents | +| --------------- | -------------------------------------------------------------------- | +| `data_sources/` | Remote and local data sources, each with a `dtos/` subfolder | +| `mappers/` | Extension methods mapping DTOs and generated classes to domain models | +| `repositories/` | Concrete repository implementations | + +**Presentation** (`{feature}_presentation/lib/`): + +| Folder | Contents | +| --------------------------------------------- | --------------------------------------------------- | +| `{screen_name}/bloc/` | `Bloc` or `Cubit`, plus state and event classes | +| `{screen_name}/views/` | Screen and widget implementations | +| `{screen_name}/{screen_name}_module.dart` | The module wiring dependencies for that screen | + +Each layer has a primary barrel file at the root of `lib/`, named `{feature}_{layer}.dart`, plus [subfeature barrel files](/architecture/ffca/presentation/#subfeature-barrel-files) for any entry point that should be independently importable. + +## Monorepo tooling + +### Dart workspaces + +Utilize [Dart workspaces](https://dart.dev/tools/pub/workspaces) to manage the monorepo. This ensures all packages within the project utilize the same versions of external packages. If conflicts appear with a transitive dependency, use Dart's tooling to identify and resolve the issue. + +### Running commands across packages + +There are two options here. Choose whichever one makes most sense for your project or scenario: + +1. **[Melos](https://melos.invertase.dev/) (recommended)**: the most widely used tool for Flutter and Dart monorepos. It handles dependency management, runs scripts across all packages, supports filtering by Dart or Flutter packages, and automates versioning and changelog generation. It works on all platforms. +2. **A `tool` folder**: if you have more intricate actions to perform, such as fetching localizations from a service for multiple feature packages, consider writing a Dart program inside a `tool` folder. See Dart's [package layout conventions](https://dart.dev/tools/pub/package-layout) page for more information. + +## Add-to-app support + +FFCA's feature isolation maps naturally to Flutter's [add-to-app](https://docs.flutter.dev/add-to-app) pattern. Each feature's [module](/architecture/ffca/presentation/#the-module) is a self-contained entry point with explicit dependencies, making it embeddable in a native host app. + +The module takes its dependencies as constructor parameters, wires its own provider tree, and communicates outward through navigation callbacks. In a full FFCA app, the app layer instantiates the module inside a `GoRouteData.build()`. In add-to-app, the module is instantiated directly as the root widget of a `FlutterEngine`. The module code is identical. The only difference is what the callbacks target, go_router routes or platform channels. + +```dart +@pragma('vm:entry-point') +void cartEntryPoint(String cartId) { + final apiClient = ApiClient(baseUrl: 'https://api.example.com'); + final cartsRepository = CartsRepository(apiClient: apiClient); + final productsRepository = ProductsRepository(apiClient: apiClient); + + const channel = MethodChannel('com.app/navigation'); + + runApp( + MaterialApp( + home: CartListModule( + id: cartId, + cartsRepository: cartsRepository, + productsRepository: productsRepository, + // Hand control back to native through a platform channel. + onProductTapped: (productId) => + channel.invokeMethod('showProduct', {'id': productId}), + onCheckoutStarted: () => channel.invokeMethod('showCheckout'), + ), + ), + ); +} +``` + +Subfeature barrels enable granular embedding. A native app can embed just one screen from a feature, via its subfeature barrel, without pulling in the entire feature, minimizing Flutter binary size. diff --git a/references/ffca_architecture.md b/references/ffca_architecture.md deleted file mode 100644 index de38f03..0000000 --- a/references/ffca_architecture.md +++ /dev/null @@ -1,782 +0,0 @@ -# Feature-First Clean Architecture (FFCA) - -> Source of truth: https://www.notion.so/verygoodventures/Feature-First-Clean-Architecture-2fb45eb3279580568023d1cf9bc00c24 -> This file is the in-plugin mirror, regenerated by `scripts/sync_reference.dart`. Do not edit by hand. - -> **This architecture is designed for:** -> - Flutter projects using AI-assisted development -> - Monorepos with multiple apps sharing features -> - Teams organized around feature ownership -> - Projects where features need to be added/removed per app -> - Codebases that need to scale predictably - -> **Consider standard VGE [Layered Architecture](https://engineering.verygood.ventures/development/architecture/architecture/) when:** -> - You have a single app with fewer than 3 features -> - You're not using AI-assisted development and the package overhead isn't justified -> - Features will never be shared across apps or teams -> -> That said, if you're using AI tools, the package overhead is near-zero (AI generates it), and the structural boundaries improve AI output quality. Consider starting with FFCA even for smaller projects. - -## Goals - -- **AI-First Architecture:** well-bounded packages give AI agents deterministic targets, reducing errors and making output consistent regardless of model or prompt -- Enable teams to ship features independently without waiting on other teams -- Establish ownership models for shared code -- Allow features to be reused in different apps. For example, you could build a feature and use it for a *point of sale* app, a *mobile* app, and an *admin* app. -- Reduce friction when multiple teams touch the same codebase -- Address shortcomings of current VGV architectural approaches: multiple blocs needing to perform the same data transformations, and the ability to share functionality across different blocs easily -- Ensure no circular dependencies (addressed by tooling) - -## Non-Goals - -- Convince enterprise orgs to change their structure -- Define team topology or ways of working -- Solve all cross-department communication challenges - -## The Structure - -Each project contains three major parts in a monorepo workspace: - -1. apps -2. features -3. shared libraries - -```mermaid -flowchart - subgraph apps["🚀 Apps"] - kiosk["kiosk_app"] - mobile["mobile_app"] - admin["admin_app"] - end - - subgraph features["✨ Features"] - subgraph product["product"] - product_domain["📦 product_domain
(Dart)"] - product_data["🗄️ product_data
(Dart)"] - product_presentation["🖼️ product_presentation
(Flutter)"] - end - subgraph cart["cart"] - cart_domain["📦 cart_domain
(Dart)"] - cart_data["🗄️ cart_data
(Dart)"] - cart_presentation["🖼️ cart_presentation
(Flutter)"] - end - subgraph auth["auth (headless)"] - auth_domain["📦 auth_domain
(Dart)"] - auth_data["🗄️ auth_data_firebase
(Dart)"] - end - subgraph analytics["analytics (headless)"] - analytics_domain["📦 analytics_domain
(Dart)"] - analytics_data["🗄️ analytics_data_posthog
(Dart)"] - end - end - - subgraph shared["🔧 Shared"] - api_client["api_client
(Dart)"] - ui_kit["ui_kit
(Flutter)"] - localizations["localizations
(Flutter)"] - logging["logging
(headless feature)"] - end - - apps --> features - apps --> shared - - product_data --> product_domain - product_presentation --> product_domain - - cart_data --> cart_domain - cart_presentation --> cart_domain - cart_domain --> product_domain - - auth_data --> auth_domain - analytics_data --> analytics_domain - - features --> shared -``` - -### Apps - -The deployable applications. In a large codebase, you might have several apps, such as a *kiosk* app, an *admin* app, and a *mobile* app. - -In this architecture, the responsibility of the app is rather limited. They compose a series of features and shared libraries to build a complete, deployable artifact usable by the target audience. - -Each app defines its own routing structure, environment configurations, bundle ids, and CI/CD workflows for verification and deployment. - -### Features - -A feature is a cohesive unit of functionality. Some features have screens, some don't. Every feature follows clean architecture with domain, data, and (optionally) presentation layers as separate packages. - -A feature with all three layers (domain + data + presentation) owns user-facing flows. For example, an e-commerce app may have a `Product` or `Cart` feature, to display a Product Screen or Shopping Cart. - -#### Headless Features - -A feature without a presentation layer is a **headless feature**. It provides business logic and data access consumed by other features but owns no screens. Common examples include `auth`, `analytics`, and `user_profile`. - -A headless feature can grow into a full feature by adding a presentation layer package. For example, `auth` starts as a headless feature (domain + data), and when a login screen is needed, adding `auth_presentation` makes it a full feature. No structural changes to the existing packages are required. - -### Shared Libraries - -Applications and features may require common, shared libraries to achieve their goals. For example, many features may need a Swagger-generated `api_client` to perform HTTP requests, or a `ui_kit` (aka design system) for common widgets. Rather than each feature defining their own `api_client`, we can use a shared library across all features. - -The distinction between shared libraries and features can be a bit fuzzy. A good rule of thumb: shared code should have zero knowledge of your app's features. Shared libraries never import from a feature folder, and their API makes sense without any feature-specific context. If you find yourself referencing a particular feature from shared, that code likely belongs in the feature instead. - -Business-domain packages like `auth`, `analytics`, and `user_profile` are features (often headless), not shared libraries. They represent business decisions: which auth provider to use, which analytics events to track, what user data to store. Different apps have different backends. For example, some apps may use Firebase Auth while others use Auth0. Some apps may use Google Analytics while others use PostHog. - -Infrastructure packages like `logging` and `secure_storage` can be either shared libraries (if generic enough for any project) or headless features (if customized for the project). A generic `ILogger` interface with a Sentry implementation belongs in `shared/`. A project-specific analytics setup with custom events belongs in `features/`. - -The decision rule: **is it a business capability that apps compose?** If yes, it belongs in `features/`. **Could you publish it to pub.dev and someone else would use it unchanged?** If yes, it belongs in `shared/`. - -| Component | Shared? | Why? | -|---|---|---| -| `PrimaryButton` | ✅ YES | It is part of the brand design system; agnostic of what it clicks. | -| `LoginButton` | ❌ NO | It implies a "Login" domain concept. Put it in `auth_presentation`. | -| `DioClient` | ✅ YES | It wraps generic HTTP logic (interceptors, timeouts). | -| `ProductApiClient` | ❌ NO | It knows about "Products" and API endpoints. Put it in `product_data`. | -| `formatCurrency()` | ✅ YES | Formats a double to string. Generic. | -| `calculateTax()` | ❌ NO | Tax rules are volatile business logic. Put it in `cart_domain` or `checkout_domain`. | - -## Naming Conventions (Enforced) - -Consistent naming enables tooling inference, AI comprehension, and mechanical validation. - -| Package | Convention | Example | -|---|---|---| -| Feature domain | `{feature}_domain` | `cart_domain` | -| Feature data | `{feature}_data` | `cart_data` | -| Feature presentation | `{feature}_presentation` | `cart_presentation` | -| Data with specific backend | `{feature}_data_{backend}` | `auth_data_firebase` | -| Shared package | descriptive name | `ui_kit`, `api_client` | - -Data packages with a backend suffix (e.g., `auth_data_firebase`) signal the backing implementation. Swapping to `auth_data_auth0` requires no structural changes. - -## Feature Layers - -Each feature is made up of 3 layers: the *domain* layer, the *data* layer, and the *presentation* layer, following the practices of [clean architecture](https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html). Each of these layers is an individual Dart or Flutter package. - -```mermaid -flowchart TB - subgraph Feature - direction TB - P["🖼 Presentation Layer
(Flutter package)"] - D["🔷 Domain Layer
(Dart package)"] - DA["🗄 Data Layer
(Dart package)"] - - P -->|"depends on"| D - DA -->|"depends on"| D - end -``` - -### Domain Layer - -The heart of the application. This layer is pure Dart, and entirely separate from "the outside world" of Flutter or concrete data sources, such as Firebase or http APIs. The domain layer defines the business models and operations of the given problem space, as well as the repositories required to perform those operations. - -#### Models - -Models, sometimes called "entities," are objects that contain the information you are trying to represent in the problem space. For example, in the context of an e-commerce app, the domain models might include a `Product` and a `Cart`. A `Product` class contains `id`, `title`, and `description` information. - -Do not add the word `Model` or `Entity` to this class. This is the core of the domain, and it should be easy to read and understand. - -#### Business Rules - -There are generally two types of rules in the domain space: commands and queries. *Commands* make modifications to the domain models and often store them in a data source. *Queries* retrieve domain models from a data source. Combined, these are often referred to as "use cases." We recommend avoiding the term "use case" and instead prefer "Command" and "Query" classes for clarity. For example: - -- `UpdateProductTitleCommand`: updates the title of a product and stores it in the data source. -- `GetProductByIdQuery`: fetches product information based on the product id. It returns a `Future`. -- `WatchProductByIdQuery`: returns a new instance of a `Product` object any time the `Product` changes. It returns a `Stream`. - -If a use case only serves to call out to the repository, there is no need to introduce a use case class. Most often, use cases are important when you need to combine several data sources together from different features through repositories, or if you find yourself performing the exact same work in several Blocs. - -#### Repositories - -You may need to load a `Product` from a local database or api. Therefore, the domain layer for the Product feature must define a way to fetch that information, without knowing how it is done. - -For this purpose, the domain defines repositories. In the domain layer, these classes are `abstract interface class` definitions. Repositories for each feature must be independent. The `Cart` domain should not define an `IProductsRepository` and vice-versa. This leads to coupling which makes refactors and data migrations difficult or impossible. - -```dart -abstract interface class IProductsRepository { - Future saveProduct(Product product); - Future getProductById(String productId); - Stream watchProductById(String productId); - Future updateProduct(Product product); - Future deleteProduct(String productId); -} -``` - -#### Combining Different Features - -In the domain layer, features often combine information and actions from other features. To solve this problem, feature domains may depend on other feature domains. For example, the Cart domain may rely on the Product domain, so that you can watch a `Cart` with populated `Product` information. - -**Storing data: the Summary pattern.** The read model holds full objects: - -```dart -class Cart { - Cart({required this.id, required this.products}); - - final String id; - final List products; -} -``` - -However, the Cart data layer should not know how to store or retrieve `Products`. The stored shape keeps only IDs. This is the "summary" object: - -```dart -class CartSummary { - CartSummary({required this.id, required this.productIds}); - - final String id; - final List productIds; -} -``` - -The cart repository works with summaries: - -```dart -abstract interface class ICartsRepository { - Future createCart(Cart cart); - Future getCartById(String cartId); - Stream watchCartById(String cartId); - Future addProductToCart(String cartId, String productId); - Future removeProductFromCart(String cartId, String productId); - Future deleteCart(String cartId); -} -``` - -**Use cases combine repositories.** To read a populated cart, introduce a use case combining `ICartsRepository` and `IProductsRepository`: - -```dart -class GetCartByIdQuery { - GetCartByIdQuery({ - required ICartsRepository cartsRepository, - required IProductsRepository productsRepository, - }) : _cartsRepository = cartsRepository, - _productsRepository = productsRepository; - - final ICartsRepository _cartsRepository; - final IProductsRepository _productsRepository; - - Future get(String cartId) async { - final cartSummary = await _cartsRepository.getCartById(cartId); - - return Cart( - id: cartSummary.id, - products: [ - for (final productId in cartSummary.productIds) - await _productsRepository.getProductById(productId), - ], - ); - } -} -``` - -### Data Layer - -The data layer of a feature is responsible for providing a concrete implementation of the domain layer's repositories. How it does that is completely up to the data layer itself. It may use a Drift database for local storage and an http api as a remote source. It may introduce an in-memory cache if necessary. Those are implementation details handled by the data layer. - -#### Data Sources - -The different storage mechanisms are known as *data sources*. The repository is responsible for mixing these data sources to fulfil the contract (interface) defined by the domain layer. - -For example, to enable some offline browsing, an eCommerce app may have a Drift database data source and a Shopify http api data source. These live within the `data` package of a feature. - -Each data source may return a DTO. For example, a `ProductDatabase` class might return a `DbProduct` generated by Drift. The `DbProduct` class knows a lot about the database and is tightly coupled to Drift. Do not leak these classes to the domain or presentation layers. - -The data layer converts DTOs from each data source into the appropriate domain model. Options: - -- Hand/AI-written extension methods, such as `dbProduct.toDomain()` -- Hand/AI-written `Converter` classes, such as `class DbToDomainProduct extends Converter` -- Libraries that generate the mapping using `build_runner`, such as `auto_mappr` - -Do not define an abstract DTO for in-memory, local, and remote storage options. It adds extra, unnecessary mapping to objects that are usually generated by drift, swagger, or other libraries. - -### Presentation Layer - -The presentation layer of each feature defines the user interface and glues everything together to display it. Generally a series of Flutter widgets; since the domain and data layers are headless, it could be adapted for a CLI app as well. - -The presentation layer: - -- Defines an entry point for the feature or parts of the feature -- Defines the screens and widgets needed to implement the feature -- Defines Blocs that tie the domain layer to the widgets - -#### Entry Point: The Module - -Each feature or part of a feature is defined by a *module*. If a feature has many screens or complex subcomponents, each one can define its own module. A module defines and sets up the required dependencies for a feature or part of a feature. This achieves three goals: - -1. Each Module clearly defines all of its dependencies. No surprises. -2. It moves dependency injection registration to a common location. -3. It enables deferred imports, important for splitting web bundles and enabling Android dynamic modules. - -Dependencies can be constructed with `InheritedWidgets`, `Providers`, `get_it`, `riverpod`, or prop drilling, so long as they achieve the goals above. An example using Provider: - -```dart -/// The module that loads the cart screen and dependencies -class CartListModule extends StatelessWidget { - const CartListModule({ - required this.id, - required this.cartsRepository, - required this.productsRepository, - super.key, - }); - - final ICartsRepository cartsRepository; - final IProductsRepository productsRepository; - final String id; - - @override - Widget build(BuildContext context) { - return Provider( - create: (context) => GetCartByIdQuery( - cartsRepository: cartsRepository, - productsRepository: productsRepository, - ), - child: BlocProvider( - create: (BuildContext context) { - return CartListCubit( - getCartByIdQuery: context.read(), - cartsRepository: cartsRepository, - ); - }, - child: CartListScreen(id: id), - ), - ); - } -} -``` - -#### Localizations - -Localizations can be either shared or per-feature. To keep it simple, start with a Flutter package in the `shared` folder and use it amongst all features in the presentation layer. If your app is very large, per-feature localizations may be worthwhile, at the cost of additional complexity. - -#### Widgets with State Management - -A widget that needs its own Cubit but depends on another feature's domain is not a separate feature. It belongs in the owning feature's presentation package and is exported through the barrel file. - -For example, a `CartBadge` that displays the item count and manages its own loading state lives in `cart_presentation`: - -```dart -// cart_presentation/lib/cart_badge/bloc/cart_badge_cubit.dart - -class CartBadgeCubit extends Cubit { - CartBadgeCubit({required ICartsRepository cartsRepository}) - : _cartsRepository = cartsRepository, - super(const CartBadgeInitial()); - - final ICartsRepository _cartsRepository; - - Future loadItemCount(String cartId) async { - emit(const CartBadgeLoading()); - try { - final cart = await _cartsRepository.getCartById(cartId); - emit(CartBadgeLoaded(itemCount: cart.productIds.length)); - } catch (e, st) { - addError(e, st); - emit(CartBadgeError(message: e.toString())); - } - } -} -``` - -Any app or other feature's presentation layer can import and use `CartBadge`. If a widget grows complex enough to require its own domain logic, it becomes its own feature. - -#### Subfeature Barrel Files - -A single feature may expose multiple independent entry points. Each subfeature gets its own barrel file, and the primary barrel re-exports everything: - -``` -features/favorites/ -└── favorites_presentation/ - └── lib/ - ├── favorites_list/ - ├── favorites_detail/ - ├── favorites_list.dart # subfeature barrel - ├── favorites_detail.dart # subfeature barrel - └── favorites_presentation.dart # primary barrel (re-exports all) -``` - -This is essential for **deferred imports**: - -```dart -import 'package:favorites_presentation/favorites_detail.dart' - deferred as favorites_detail; -``` - -With a single barrel file, you can't defer-load part of a package. Subfeature barrels enable fine-grained code splitting for web bundles and Android dynamic modules. - -#### Routing & Navigation - -Features are strictly isolated and cannot know about the application's global routing table. A feature (e.g., `Cart`) cannot directly import the routes of another feature (e.g., `Product`). Navigation is treated as a dependency. - -**The pattern: callback injection.** The app layer (the "Assembler") defines *what happens next*. The feature layer detects *when* that action is needed. - -For simple features with shallow widget trees, pass callbacks directly into the Module's constructor: - -```dart -// The App Layer assembles the feature -CartListModule( - onProductTapped: (id) => router.push('/product/$id'), -); -``` - -For complex features with deep widget trees, define a **Navigation class** in the feature's presentation layer and provide it to the subtree: - -```dart -class CartNavigation { - CartNavigation({ - required this.onProductTapped, - required this.onCheckoutStarted, - }); - - final void Function(String productId) onProductTapped; - final VoidCallback onCheckoutStarted; -} -``` - -```dart -// Inside a deeply nested widget -InkWell( - onTap: () => context.read().onProductTapped(product.id), - child: ProductCard(...), -) -``` - -**Alternative: Flutter's Actions & Intents.** The Module registers Actions at the top of the tree, and deep widgets invoke typed Intents: - -```dart -return Actions( - actions: { - ProductTappedIntent: CallbackAction( - handler: (intent) => onProductTapped(intent.productId), - ), - }, - child: BlocProvider( - create: (_) => CartListCubit(...), - child: const CartListScreen(), - ), -); - -// Deep in the tree, no prop drilling, no Provider needed: -InkWell( - onTap: () => Actions.invoke(context, ProductTappedIntent(product.id)), - child: ProductCard(...), -) -``` - -Trade-off: like Provider, Actions are resolved at runtime. Callbacks at the Module boundary remain the compile-time-safe cross-feature contract in both approaches. - -**Recommended implementation: go_router + go_router_builder.** The app layer defines `GoRouteData` classes that instantiate feature Modules with callback wiring: - -```dart -@TypedGoRoute(path: '/cart') -class CartListRoute extends GoRouteData { - @override - Widget build(BuildContext context, GoRouterState state) { - return CartListModule( - cartsRepository: context.read(), - productsRepository: context.read(), - onProductSelected: (id) => ProductDetailRoute(id: id).go(context), - onCheckoutRequested: () => CheckoutRoute().go(context), - ); - } -} -``` - -Routing constraints: - -- Features never import the app's router configuration or another feature's routes. -- All navigation uses generated type-safe route classes (e.g., `ProductDetailRoute(id: id).go(context)`), never string-based paths like `context.push('/product/123')`. - -**Data hydration for deep links.** Screens must always be rebuildable from URL parameters alone. `$extra` is a volatile optimization (lost on browser refresh, process death, cold-start deep links), so every screen handles it being null: - -```dart -@TypedGoRoute(path: '/product/:id') -class ProductDetailRoute extends GoRouteData { - const ProductDetailRoute({required this.id, this.$extra}); - - final String id; - final Product? $extra; - - @override - Widget build(BuildContext context, GoRouterState state) { - return ProductDetailModule( - productId: id, - initialProduct: $extra, // performance optimization, may be null - ); - } -} -``` - -The Bloc checks if `initialProduct` is available. If so, it emits `Loaded` immediately. If not, it fetches by `productId` and shows a loading state. - -## Dependency Graph Rules - -1. Apps depend on features + shared packages -2. Shared packages may not depend on anything other than external packages -3. Features depend on shared libraries + other domains: - - The domain layer does not import anything else from the feature - - The data layer imports the domain layer so it can implement the repository interface - - The presentation layer imports the domain to assemble the feature - -```mermaid -flowchart TB - subgraph Domain - Models["🧩 Models"] - RepoInterface["📋 Repository Interface"] - UseCases["⚙️ Use Cases (optional)"] - UseCases -->|"maps to"| Models - UseCases --> RepoInterface - end - - subgraph Data - DTO["📦 DTO"] - DataSources["🌐 Data Sources"] - RepoImpl["🗄️ Repository Impl"] - Mappers["🔄 Mappers"] - RepoImpl --> DataSources - RepoImpl --> Mappers - DataSources -->|"uses"| DTO - end - - subgraph Presentation - BLoC["⚡ BLoC / Controller"] - Widget["🖼️ Widget / View"] - Widget --> BLoC - end - - Presentation --> Domain - BLoC --> UseCases - RepoImpl -.->|"implements"| RepoInterface - RepoInterface -->|"uses"| Models - Mappers <-->|"maps to/from"| Models - Mappers --> DTO -``` - -## Monorepo Tooling - -**Dart Workspaces & dependencies:** use [Dart Workspaces](https://dart.dev/tools/pub/workspaces) to manage the monorepo so all packages use the same versions of external packages. - -**Running commands on multiple packages:** - -1. `Melos` **(recommended)**: the most widely used tool for Flutter/Dart monorepos. Dependency management, scripts across packages, Dart/Flutter filtering, versioning and changelog automation. Works on all platforms. -2. `Makefile`: simple action organization, POSIX only. -3. `tool` folder: a Dart program for intricate actions. See Dart's [Package Layout Conventions](https://dart.dev/tools/pub/package-layout). - -## Example Folder Structure - -``` -├── apps/ -│ ├── kiosk_app/ # Flutter package -│ ├── mobile_app/ # Flutter package -│ └── admin_app/ # Flutter package -├── features/ -│ ├── product/ -│ │ ├── product_domain/ # Dart package -│ │ │ └── lib/ -│ │ │ ├── models/ -│ │ │ │ └── product.dart -│ │ │ ├── repositories/ -│ │ │ │ └── i_products_repository.dart -│ │ │ └── product_domain.dart # barrel file -│ │ ├── product_data/ # Dart package -│ │ │ └── lib/ -│ │ │ ├── data_sources/ -│ │ │ │ └── products_remote_data_source/ -│ │ │ │ ├── dtos/ -│ │ │ │ │ └── product_dto.dart -│ │ │ │ └── products_remote_data_source.dart -│ │ │ ├── mappers/ -│ │ │ │ └── product_mapper.dart -│ │ │ ├── repositories/ -│ │ │ │ └── products_repository.dart -│ │ │ └── product_data.dart # barrel file -│ │ └── product_presentation/ # Flutter package -│ │ └── lib/ -│ │ ├── product_detail/ -│ │ │ ├── bloc/ -│ │ │ ├── views/ -│ │ │ └── product_detail_module.dart -│ │ ├── product_list/ -│ │ │ ├── bloc/ -│ │ │ ├── views/ -│ │ │ └── product_list_module.dart -│ │ ├── product_detail.dart # subfeature barrel -│ │ ├── product_list.dart # subfeature barrel -│ │ └── product_presentation.dart # primary barrel -│ ├── cart/ # depends on product_domain -│ │ ├── cart_domain/ -│ │ ├── cart_data/ -│ │ └── cart_presentation/ -│ ├── auth/ # headless feature (no presentation) -│ │ ├── auth_domain/ -│ │ └── auth_data_firebase/ -│ ├── analytics/ # headless feature -│ │ ├── analytics_domain/ -│ │ └── analytics_data_posthog/ -│ └── user_profile/ # headless feature -│ ├── user_profile_domain/ -│ └── user_profile_data/ -└── shared/ - ├── api_client/ # single package - ├── ui_kit/ # single package - ├── localizations/ # single package - └── logging/ # headless feature (generic) - ├── logging_domain/ - └── logging_data_sentry/ -``` - -### Layer Subfolder Conventions - -**Domain** (`{feature}_domain/lib/`): - -| Folder | Contents | -|---|---| -| `models/` | Domain models (pure Dart classes with value equality) | -| `repositories/` | Repository interfaces (`abstract interface class`) | -| `use_cases/` | Query and Command classes (optional: only when combining multiple repositories) | - -**Data** (`{feature}_data/lib/`): - -| Folder | Contents | -|---|---| -| `data_sources/` | Remote and local data sources, each with a `dtos/` subfolder for DTOs | -| `mappers/` | Extension methods mapping DTOs or generated classes to domain models | -| `repositories/` | Concrete repository implementations | - -**Presentation** (`{feature}_presentation/lib/`): - -| Folder | Contents | -|---|---| -| `{screen_name}/bloc/` | Cubit/Bloc + state (+ event) classes for each screen or widget | -| `{screen_name}/views/` | Screen and widget implementations | -| `{screen_name}/{screen_name}_module.dart` | Module wiring dependencies for the screen | - -Each layer has a primary barrel file at the root of `lib/` named `{feature}_{layer}.dart`, plus subfeature barrel files for independently importable entry points. - -## Add-to-App Support - -FFCA's feature isolation maps naturally to Flutter's [add-to-app](https://docs.flutter.dev/add-to-app) pattern. Each feature's Module widget is a self-contained entry point with explicit dependencies, making it embeddable in a native host app. - -The Module takes its dependencies as constructor parameters, wires its own Provider/BlocProvider tree, and communicates outward through navigation callbacks. In a full FFCA app, the app layer instantiates the Module inside a `GoRouteData.build()`. In add-to-app, the Module is instantiated directly as the root widget of a `FlutterEngine`. The Module code is identical: the only difference is what the callbacks target (go_router routes vs. platform channels). - -```dart -@pragma('vm:entry-point') -void cartEntryPoint() { - final apiClient = ApiClient(baseUrl: 'https://api.example.com'); - final cartsRepository = CartsRepository(apiClient: apiClient); - final productsRepository = ProductsRepository(apiClient: apiClient); - - runApp( - MaterialApp( - home: CartListModule( - cartsRepository: cartsRepository, - productsRepository: productsRepository, - onProductSelected: (id) { - // Send back to native via platform channel - MethodChannel('com.app/navigation') - .invokeMethod('showProduct', {'id': id}); - }, - ), - ), - ); -} -``` - -Subfeature barrels enable granular embedding: a native app can embed just one screen from a feature without pulling in the entire feature, minimizing Flutter binary size. - -## Open Discussions - -The following topics are under active discussion and may evolve the architecture: - -- **Component + Builder pattern:** whether explicit dependency contract interfaces (inspired by RIBs) should replace or augment the Module pattern, providing compile-time DI verification. -- **Widget-tree composition vs. domain use cases:** whether features should avoid depending on other features' domains, composing data in the widget tree instead. - -## FAQ - -### Should we use callable classes for Use Cases? - -No. Callable classes look great at the call site but break code navigation ("find all usages" of `call` mixes different callable classes; jump-to-definition fails unless you explicitly use `call`). Use these verbs: - -- Commands: `execute` method -- Queries: `get` or `watch` method, depending on whether it returns a `Future` or a `Stream` - -### How do I handle Auth and User Profiles? - -Identity (authentication) and Entity (such as a User Profile) are two separate domains. Each should be a separate feature. If the `Auth` feature returns a full `UserProfile` object, you couple authentication logic to business data: every new profile field forces a change to `Auth`. - -`auth_domain`: - -```dart -class AuthUser { - const AuthUser({ - required this.id, - required this.email, - this.isEmailVerified = false, - }); - - final String id; - final String email; - final bool isEmailVerified; -} - -abstract interface class IAuthRepository { - Future getCurrentUser(); - Stream watchCurrentUser(); -} -``` - -`user_profile_domain`: - -```dart -class UserProfile { - const UserProfile({ - required this.id, - required this.username, - required this.avatarUrl, - }); - - final String id; - final String username; - final String avatarUrl; -} - -abstract interface class IUserProfilesRepository { - Future getUserProfile(String userId); - Stream watchUserProfile(String userId); -} -``` - -Glue them with a query in `user_profile_domain` (the consuming feature): - -```dart -class WatchCurrentUserProfileQuery { - WatchCurrentUserProfileQuery({ - required IAuthRepository authRepository, - required IUserProfilesRepository userProfilesRepository, - }) : _authRepository = authRepository, - _userProfilesRepository = userProfilesRepository; - - final IAuthRepository _authRepository; - final IUserProfilesRepository _userProfilesRepository; - - Stream watch() { - return _authRepository.watchCurrentUser().switchMap((authUser) { - if (authUser == null) { - return Stream.value(null); - } else { - return _userProfilesRepository.watchUserProfile(authUser.id); - } - }); - } -} -``` - -### What if the backend has one large OpenAPI / Swagger definition for all endpoints? - -Generate a Dart package in `shared/` that wraps the Swagger client, with a Dart script in its `tool/` folder that downloads the latest spec and regenerates the client (e.g., with `swagger_to_dart`, using Dio to match the cookbook's `api_client` recipe). Each feature uses the shared client inside its `data` package: check local DB, fetch via the shared client, store locally, map the API DTO to the domain model. - -### Dealing with nested API objects - -Normalizing data (storing a `User` in one table and a `Photo` in another) belongs strictly to the data layer. The domain shouldn't know the cache is normalized; it just wants a `PhotoSummary`. Keep the logic in the repository, not a use case: - -1. **Inject the interface:** `PhotosRepository` (in `photos_data`) depends on `IUsersRepository` (from `users_domain`). Valid dependency: Data → Domain. -2. **Intercept and split:** when the API returns nested JSON, the repository strips out the `User` data and sends it to `IUsersRepository` before saving the `Photo` as a summary (IDs only). - -The DTO-to-domain mapping for the nested object lives inside `photos_data`, not imported from `users_data`. This duplicates a mapper but keeps `photos_data` decoupled from `users_data`: an explicit trade-off of DRY in favor of bounded contexts. diff --git a/scripts/sync_reference.dart b/scripts/sync_reference.dart index 919bd00..bc29a42 100644 --- a/scripts/sync_reference.dart +++ b/scripts/sync_reference.dart @@ -1,22 +1,168 @@ -// Regenerates references/ffca_architecture.md from the canonical Notion page. +// Regenerates references/ffca/ from the canonical VGV Engineering docs. // -// Source of truth: -// https://www.notion.so/verygoodventures/Feature-First-Clean-Architecture-2fb45eb3279580568023d1cf9bc00c24 +// Source of truth: https://engineering.verygood.ventures/architecture/ffca/ // -// TODO: implement the sync. The intended flow is to take a Notion export of the -// FFCA architecture page and regenerate references/ffca_architecture.md from it, -// so the in-plugin mirror never drifts from the source. A CI check (or scheduled -// workflow) then flags when the committed copy is stale. The provided -// references/ffca_architecture.md is current as of today and maintained by hand -// until this automation lands. +// Every page on engineering.verygood.ventures serves a clean Markdown twin at +// the same path with a `.md` extension (see https://engineering.verygood.ventures/llms.txt). +// This script fetches those files verbatim, so the in-plugin mirror is a byte +// copy of upstream rather than a hand-maintained paraphrase. +// +// Usage: +// dart run sync_reference.dart # rewrite the mirror +// dart run sync_reference.dart --check # fail (exit 1) if the mirror is stale +// +// Imports only dart:io and dart:convert, so it runs with just the Dart SDK. +import 'dart:convert'; import 'dart:io'; -void main() { - stderr.writeln( - 'sync_reference.dart is not implemented yet. references/ffca_architecture.md ' - 'is currently maintained by hand from the Notion page. See the TODO at the ' - 'top of this file.', - ); - exit(64); +/// The base URL each page is fetched from. +const baseUrl = 'https://engineering.verygood.ventures/architecture/ffca'; + +/// The pages that make up the FFCA documentation, in reading order. +/// +/// The order is the one upstream recommends in "Where to go next", and it is +/// the order the generated manifest lists them in. +const pages = [ + 'overview', + 'domain', + 'data', + 'presentation', + 'navigation', + 'project_structure', + 'faq', +]; + +Future main(List args) async { + final check = args.contains('--check'); + final dir = _mirrorDirectory(); + + final fetched = {}; + for (final page in pages) { + final url = '$baseUrl/$page.md'; + try { + fetched[page] = await _fetch(url); + } on Object catch (error) { + stderr.writeln('Failed to fetch $url: $error'); + return exitCode = 70; + } + } + fetched['README'] = _manifest(); + + final stale = []; + for (final entry in fetched.entries) { + final file = File('${dir.path}/${entry.key}.md'); + final current = file.existsSync() ? file.readAsStringSync() : null; + if (current == entry.value) continue; + stale.add('${entry.key}.md'); + if (!check) { + file.parent.createSync(recursive: true); + file.writeAsStringSync(entry.value); + } + } + + // Files in the mirror that upstream no longer publishes. + final orphans = []; + if (dir.existsSync()) { + for (final entity in dir.listSync()) { + if (entity is! File || !entity.path.endsWith('.md')) continue; + final name = entity.uri.pathSegments.last; + if (fetched.containsKey(name.substring(0, name.length - 3))) continue; + orphans.add(name); + if (!check) entity.deleteSync(); + } + } + + if (check) { + if (stale.isEmpty && orphans.isEmpty) { + stdout.writeln('references/ffca/ is up to date with $baseUrl.'); + return 0; + } + stderr + ..writeln('references/ffca/ is stale.') + ..writeln(); + for (final name in stale) { + stderr.writeln(' out of date: $name'); + } + for (final name in orphans) { + stderr.writeln(' no longer published upstream: $name'); + } + stderr + ..writeln() + ..writeln( + 'Run `dart run scripts/sync_reference.dart` and commit the ' + 'result.', + ); + return exitCode = 1; + } + + if (stale.isEmpty && orphans.isEmpty) { + stdout.writeln('references/ffca/ was already up to date.'); + return 0; + } + for (final name in stale) { + stdout.writeln('updated $name'); + } + for (final name in orphans) { + stdout.writeln('removed $name (no longer published upstream)'); + } + return 0; +} + +/// Resolves `references/ffca/`, which sits next to this script's parent. +Directory _mirrorDirectory() { + final scriptDir = File.fromUri(Platform.script).parent; + return Directory('${scriptDir.parent.path}/references/ffca'); +} + +/// Fetches [url] as UTF-8 text, following redirects. +Future _fetch(String url) async { + final client = HttpClient(); + try { + final request = await client.getUrl(Uri.parse(url)); + final response = await request.close(); + if (response.statusCode != 200) { + throw HttpException('HTTP ${response.statusCode}', uri: Uri.parse(url)); + } + return await response.transform(utf8.decoder).join(); + } finally { + client.close(); + } +} + +/// Builds the manifest that documents where the mirror comes from. +String _manifest() { + final rows = pages + .map((page) => '| [`$page.md`]($page.md) | <$baseUrl/$page/> |') + .join('\n'); + + return ''' +# FFCA Reference Mirror + +Do not edit these files by hand. They are byte copies of the canonical FFCA +documentation on [VGV Engineering][vge], regenerated by +`scripts/sync_reference.dart`. Any local edit is overwritten on the next sync +and reported as drift by CI. + +| File | Source | +| --- | --- | +$rows + +To refresh the mirror: + +```sh +dart run scripts/sync_reference.dart +``` + +To verify it matches upstream without writing anything: + +```sh +dart run scripts/sync_reference.dart --check +``` + +Fix the architecture at the source. Changes belong on [VGV Engineering][vge], +and land here through a sync. + +[vge]: https://engineering.verygood.ventures/architecture/ffca/overview/ +'''; } diff --git a/scripts/test/fixtures/valid_workspace/apps/mobile_app/pubspec.yaml b/scripts/test/fixtures/valid_workspace/apps/mobile_app/pubspec.yaml index 90d52cb..f0d2e87 100644 --- a/scripts/test/fixtures/valid_workspace/apps/mobile_app/pubspec.yaml +++ b/scripts/test/fixtures/valid_workspace/apps/mobile_app/pubspec.yaml @@ -6,6 +6,8 @@ dependencies: path: ../../features/product/product_presentation cart_presentation: path: ../../features/cart/cart_presentation + ideas_presentation: + path: ../../features/ideas/ideas_presentation ui_kit: path: ../../shared/ui_kit api_client: diff --git a/scripts/test/fixtures/valid_workspace/features/ideas/ideas_presentation/pubspec.yaml b/scripts/test/fixtures/valid_workspace/features/ideas/ideas_presentation/pubspec.yaml new file mode 100644 index 0000000..2d533af --- /dev/null +++ b/scripts/test/fixtures/valid_workspace/features/ideas/ideas_presentation/pubspec.yaml @@ -0,0 +1,14 @@ +# A presentation-only feature: it composes other features' domains into a +# screen and owns no domain or data of its own, so it has no siblings. +name: ideas_presentation +environment: + sdk: ^3.11.0 +dependencies: + product_domain: + path: ../../product/product_domain + cart_domain: + path: ../../cart/cart_domain + cart_presentation: + path: ../../cart/cart_presentation + ui_kit: + path: ../../../shared/ui_kit diff --git a/scripts/test/validate_layers_test.dart b/scripts/test/validate_layers_test.dart index 7fa0f6a..1329281 100644 --- a/scripts/test/validate_layers_test.dart +++ b/scripts/test/validate_layers_test.dart @@ -62,6 +62,7 @@ void main() { 'features/cart/cart_presentation', 'features/auth/auth_domain', 'features/auth/auth_data_firebase', + 'features/ideas/ideas_presentation', 'shared/ui_kit', 'shared/api_client', ]; @@ -71,6 +72,18 @@ void main() { expect(r.err, isEmpty, reason: '$pkg should be silent on pass'); } }); + + test('accepts a presentation-only feature with no domain sibling', () { + // features/ideas/ holds only ideas_presentation, which composes + // product_domain and cart_domain. A missing sibling is a signal about + // what the feature is, never a violation. + final r = _run([ + '--file', + _pubspec(_valid, 'features/ideas/ideas_presentation'), + ]); + expect(r.code, 0, reason: r.err); + expect(r.err, isEmpty); + }); }); group('invalid workspace --all catches every rule', () { diff --git a/scripts/validate_layers.dart b/scripts/validate_layers.dart index 5dda4b6..089558d 100644 --- a/scripts/validate_layers.dart +++ b/scripts/validate_layers.dart @@ -18,8 +18,8 @@ import 'dart:io'; // Rules // // This is the single source of truth for the layer dependency policy. It -// implements the "Dependency Graph Rules" section of -// references/ffca_architecture.md (and the checks table in the plugin spec). +// implements the "Dependency rules" section of +// references/ffca/project_structure.md. // Change cross-feature dependency scope here, not throughout the script. // // Each key is a source layer; the value is the set of target layers it may @@ -225,9 +225,8 @@ _Violation _dependencyViolation(Package src, Package tgt) { package: src, rule: '$pair (a ${src.layer} layer must not depend on a ${tgt.layer} ' 'layer)', - fix: - 'Remove the dependency on ${tgt.name}; review the Dependency Graph ' - 'Rules in references/ffca_architecture.md.', + fix: 'Remove the dependency on ${tgt.name}; review the Dependency ' + 'rules in references/ffca/project_structure.md.', ); } } diff --git a/skills/ffca-architecture/SKILL.md b/skills/ffca-architecture/SKILL.md index fe1e4d9..e618e19 100644 --- a/skills/ffca-architecture/SKILL.md +++ b/skills/ffca-architecture/SKILL.md @@ -8,7 +8,7 @@ effort: high # FFCA Architecture -Orientation skill for Feature-First Clean Architecture (FFCA) monorepos. It tells you how to navigate the structure and which rules apply. It does not restate the conventions: those live in `references/ffca_architecture.md`, and every step below points you to the section to read. +Orientation skill for Feature-First Clean Architecture (FFCA) monorepos. It tells you how to navigate the structure and which rules apply. It does not restate the conventions: those live in `${CLAUDE_PLUGIN_ROOT}/references/ffca/`, a byte mirror of the canonical docs at . Every step below names the file and section to read. ## Confirm you are in an FFCA repo @@ -16,33 +16,48 @@ The detection signal is a `features/` folder whose packages follow the `{feature ## Workflow: deciding where code lives -1. Read `references/ffca_architecture.md`, section *The Structure*, to place the work in one of the three top-level folders: `apps/`, `features/`, `shared/`. -2. To decide between a feature and a shared package, apply the decision rule in section *Shared Libraries* (business capability an app composes goes to `features/`; a generic, pub-publishable utility goes to `shared/`). Use its component table to settle edge cases. -3. To decide whether a feature needs a presentation layer, read section *Headless Features*. A feature with no screens stays domain plus data and can grow a presentation package later with no structural change. -4. Before naming any package, read section *Naming Conventions (Enforced)*. The names are mechanically validated by the hook, so they are not optional. -5. To place a file inside a package, read section *Layer Subfolder Conventions* for the per-layer folder map (`models/`, `repositories/`, `use_cases/`, `data_sources/`, `dtos/`, `mappers/`, and the per-screen `bloc/`, `views/`, module layout). +1. Read `references/ffca/overview.md`, section *The structure*, to place the work in one of the three top-level folders: `apps/`, `features/`, `shared/`. +2. To decide between a feature and a shared package, apply the two-question decision rule in `references/ffca/overview.md`, section *Shared libraries* (a business capability an app composes goes to `features/`; something you could publish to pub.dev and a stranger would use unchanged goes to `shared/`). Use its component table to settle edge cases. +3. To decide which layers a feature needs, read `references/ffca/overview.md`, sections *Headless features* and *Presentation-only features*. A feature with no screens stays domain plus data and can grow a presentation package later with no structural change. A feature that only composes other features' domains into a screen has a presentation package and nothing else. +4. Before naming any package, read `references/ffca/project_structure.md`, section *Naming conventions*. The names are mechanically validated by the hook, so they are not optional. +5. To place a file inside a package, read `references/ffca/project_structure.md`, section *Layer subfolders*, for the per-layer folder map (`models/`, `repositories/`, `use_cases/`, `data_sources/`, `dtos/`, `mappers/`, and the per-screen `bloc/`, `views/`, module layout). + +## Vocabulary: Command and Query, not use case + +The classes in `use_cases/` are named **Command** (mutates, `execute`) and **Query** (reads, `get` or `watch`). Use those words when you talk about them and when you name them. The folder keeps the conventional `use_cases/` name so the layout matches other clean architecture projects, but the term "use case" is not used for the classes. Read `references/ffca/domain.md`, section *Business rules*. ## Before you wire dependencies -Read `references/ffca_architecture.md`, section *Dependency Graph Rules*, before adding any path dependency to a pubspec. The hook enforces these rules on every pubspec edit and blocks the edit on a violation, so confirm the direction first: +Read `references/ffca/project_structure.md`, section *Dependency rules*, before adding any path dependency to a pubspec. That section's table is the whole policy, and it is what `scripts/validate_layers.dart` implements. The hook enforces it on every pubspec edit and blocks the edit on a violation, so confirm the direction first: - apps depend on features and shared -- shared depends only on external and other shared packages -- the domain layer imports nothing else from its feature -- the data layer imports its own domain -- the presentation layer imports domains, never a data layer +- shared depends only on external packages +- the domain layer imports other domains and shared, nothing else +- the data layer imports its own domain, other domains, and shared +- the presentation layer imports domains and other presentation packages, never a data layer + +Two absences carry as much weight as the rows: no presentation package depends on any `_data` package, its own included, and no shared package depends on a feature. + +## Deferred loading constrains the graph + +An app can load a feature on demand with a `deferred as` import, which is what splits web bundles and backs Android deferred components. This only works if the deferred package is not *also* reachable from the app through a non-deferred path. If `product_presentation` imports `cart_presentation` eagerly, deferring `cart` from the router achieves nothing. + +The failure is silent: nothing breaks, no analyzer warning fires, and the bundle just stops splitting. Treat it as a rule, not a review note. Read `references/ffca/project_structure.md`, section *Deferred loading*. On an app shipping only to iOS and Android the AOT snapshot contains the whole program anyway, so this is informational there. ## Anti-patterns to reject These are the violations the architecture exists to prevent. Read the cited sections for the rationale: -- Presentation importing a data layer. Depend on the domain and go through its repository interface. See section *Dependency Graph Rules*. -- A shared package depending on a feature. See section *Shared Libraries*. -- Business logic living in `shared/`. Volatile, app-specific logic belongs in a feature domain. See the component table in section *Shared Libraries*. +- Presentation importing a data layer. Depend on the domain and go through its repository interface. See `references/ffca/project_structure.md`, section *Dependency rules*. +- A shared package depending on a feature. See `references/ffca/overview.md`, section *Shared libraries*. +- Business logic living in `shared/`. Volatile, app-specific logic belongs in a feature domain. See the component table in the same section. +- A widget in `ui_kit` that needs a repository. It cannot compile there, because shared packages depend on external packages only. Split it: the presentational view goes to `ui_kit`, the stateful part stays in its feature. See `references/ffca/presentation.md`, section *Where the widget should live*. ## Where to go next - Creating or extending a feature: use the `ffca-feature` skill. - Routes, navigation, or deep links: use the `ffca-routing` skill. -- One feature needing another's data: use the `ffca-cross-feature` skill. +- One feature needing another's data or widgets: use the `ffca-cross-feature` skill. - Checking the whole repo's health: use the `ffca-audit` skill. + +For a worked example of the whole structure, see the [mealify reference implementation](https://github.com/VGVentures/mealify_feature_first). diff --git a/skills/ffca-audit/SKILL.md b/skills/ffca-audit/SKILL.md index 145647d..7557d2e 100644 --- a/skills/ffca-audit/SKILL.md +++ b/skills/ffca-audit/SKILL.md @@ -15,7 +15,7 @@ The audit itself is performed by the **`ffca-layer-auditor` agent**, so the work ## What to do 1. Confirm the repo is FFCA-shaped (a `features/` folder with `{feature}_{layer}` packages). If not, say so and stop. -2. Launch the `ffca-layer-auditor` agent (subagent type `ffca-layer-auditor`) against the repo. The agent runs `dart run scripts/validate_layers.dart --all` for the mechanical pass (naming, layer rules, cycles), then adds the source-level checks (declared-but-unused dependencies, barrel hygiene, DTO leakage, use-case necessity, module entry, misplaced packages, high fan-in) and reads `references/ffca_architecture.md` for the detail behind each. +2. Launch the `ffca-layer-auditor` agent (subagent type `ffca-layer-auditor`) against the repo. The agent runs `dart run scripts/validate_layers.dart --all` for the mechanical pass (naming, layer rules, cycles), then adds the source-level checks (declared-but-unused dependencies, barrel hygiene, DTO leakage, Command/Query necessity, module entry, misplaced packages, high fan-in) and reads `${CLAUDE_PLUGIN_ROOT}/references/ffca/` for the detail behind each. 3. Relay the agent's output: the per-package verdict table and the severity-ordered findings list. Each finding states the rule and the fix. The agent is read-only and does not auto-fix. If the agent is unavailable for any reason, fall back to running `dart run ${CLAUDE_PLUGIN_ROOT}/scripts/validate_layers.dart --all` yourself and reporting its violations, then note that the qualitative source-level checks were skipped. diff --git a/skills/ffca-cross-feature/SKILL.md b/skills/ffca-cross-feature/SKILL.md index 454d19c..639210e 100644 --- a/skills/ffca-cross-feature/SKILL.md +++ b/skills/ffca-cross-feature/SKILL.md @@ -1,36 +1,57 @@ --- name: ffca-cross-feature -description: "Cross-feature dependencies in FFCA: the Summary pattern, use cases that combine repositories, composing features, and feature-to-feature communication." -when_to_use: Use when one feature needs data or functionality from another feature, when sharing models across features, or when deciding between a use case and a new composing feature. +description: "Cross-feature dependencies in FFCA: the Summary pattern, Queries that combine repositories, sharing widgets between features, widget slots, and composing features." +when_to_use: Use when one feature needs data, functionality, or a widget from another feature, when sharing models or widgets across features, or when deciding between a Query, a widget slot, and a new composing feature. allowed-tools: Read Glob Grep effort: high --- # FFCA Cross-Feature -Workflow for the moment one feature needs another. The patterns live in `references/ffca_architecture.md`: read the cited sections before coupling two features. +Workflow for the moment one feature needs another. The patterns live in `${CLAUDE_PLUGIN_ROOT}/references/ffca/`: read the cited sections before coupling two features. -## The one allowed coupling: domain to domain +## The two allowed couplings -Features couple only at the domain layer, and only in one direction. A consuming feature's domain may depend on a provider feature's domain (for example `cart_domain` on `product_domain`). Data layers and presentation layers never reach across features for data. Read section *Combining Different Features*. +Features couple in exactly two places, and in one direction each: -## Decide how to combine +- **Domain to domain.** A consuming feature's domain may depend on a provider feature's domain, for example `cart_domain` on `product_domain`. Read `references/ffca/domain.md`, section *Composing features*. +- **Presentation to presentation.** A presentation package may use a public widget from another feature's presentation package, under the three conditions below. Read `references/ffca/presentation.md`, section *Sharing a widget across features*. -1. **Loose coupling by id: the Summary pattern.** When a feature stores references to another feature's models, store ids only. The read model holds full objects; the stored summary holds ids. Read the *Summary pattern* in section *Combining Different Features* and adapt the `Cart`/`CartSummary` shapes. -2. **Combine repositories with a use case.** To assemble a populated model from two features, add a `Query` or `Command` in the consuming feature's domain that takes both repository interfaces. Read *Use cases combine repositories* in the same section. Keep the use case in the consumer, never in the provider. -3. **Normalize inside the repository, not a use case.** When an API returns nested objects belonging to another feature, split them inside the data repository (data may depend on another feature's domain interface). Read the FAQ entry *Dealing with nested API objects*. The DTO-to-domain mapping is duplicated in the consuming data package on purpose, to keep bounded contexts decoupled. +Data layers never reach across features for data, and no layer ever depends on another feature's data package. -## When a use case is not enough: compose a feature +## Sharing data -If multi-domain logic grows complex (for example a checkout that needs both cart and product), create a composing feature whose domain depends on several other domains. Read section *Features* and *Combining Different Features* to judge the boundary. A new feature is warranted when the combined logic has its own models, screens, or lifecycle. +1. **Loose coupling by id: the Summary pattern.** When a feature stores references to another feature's models, store ids only. The read model holds full objects (`Cart` with `List`); the stored summary holds ids (`CartSummary` with `List`). The repository reads and writes summaries and only takes the full object when one is being created. Read `references/ffca/domain.md`, section *Storing data*, and adapt the shapes in `references/code_templates/domain_templates.md`. +2. **Combine repositories with a Query.** To assemble a populated model from two features, add a `Query` in the consuming feature's domain that takes both repository interfaces. Read `references/ffca/domain.md`, section *Reading the populated object*. Keep the Query in the consumer, never in the provider: the cart feature never learns how products are stored, and the product feature never learns that carts exist. +3. **Normalize inside the repository, not a Query.** When an API returns nested objects belonging to another feature, split them inside the data repository, which may depend on another feature's domain interface. Read `references/ffca/faq.md`, section *Dealing with nested objects*. The DTO-to-domain mapping is duplicated in the consuming data package on purpose: DRY is traded for bounded contexts here deliberately. -## How features talk at runtime +Do not call these classes "use cases". They are Commands and Queries. See `references/ffca/domain.md`, section *Business rules*. -Features never hold references to each other. Read section *Presentation Layer*: +## Sharing a widget -- Shared state belongs to a Bloc provided at the app level; features dispatch events to it, they do not know each other. -- A reusable widget that needs its own Cubit but uses another feature's domain lives in the owning feature's presentation package and is exported through its barrel. See *Widgets with State Management*. It does not become a new feature unless it grows its own domain logic. +A widget that owns a `Cubit` is not a separate feature. It lives in its own feature's presentation package and is exported through a barrel. Two ways to get it onto another feature's screen, and the choice is about who places it. + +**Direct import behind a narrow barrel.** Three conditions, all from `references/ffca/presentation.md`, section *Sharing a widget across features*: + +- The widget gets its own barrel. Consumers import `package:cart_presentation/cart_badge.dart`, never the primary `cart_presentation.dart` barrel. +- The barrel stays narrow. Its transitive imports must not reach the feature's modules or screens. Going through the primary barrel costs the consumer every screen and Cubit the feature owns, and it breaks deferred loading downstream. +- No cycles. If two features each need a widget from the other, extract the shared part downward into a third package instead of importing sideways. + +**A slot the app fills.** The consuming feature never learns the other feature exists: it declares a nullable `Widget` parameter and the app supplies it. Same inversion as navigation. Name the slot for its position (`trailingAction`), default it to nothing, and stop at two slots per module. Read `references/ffca/presentation.md`, section *Or let the app place it*. + +**Choosing.** Use a slot when the widget is app chrome, such as a cart badge in an app bar, which belongs to the shell rather than to the screen around it. Use a direct import when the widget is genuinely part of what the consuming screen *is*. Read `references/ffca/presentation.md`, section *Choosing between them*. + +## Where a shared widget should live + +When the same widget is wanted in more than one feature, work through `references/ffca/presentation.md`, section *Where the widget should live*, in order: + +1. **Does it need a repository or a domain type?** If not, it is a pure presentational component and belongs in `ui_kit`. This test is mechanical, not stylistic: shared packages depend on external packages only, so a widget that needs a repository cannot compile there. +2. **If it does, split it.** The view taking a plain count goes in `ui_kit`; the Cubit plus that view stays in its feature behind its own barrel. A widget that genuinely gets reused across features is usually a presentational leaf with the stateful part left behind, which means the pressure was pointing at `ui_kit` all along. + +## When a Query is not enough: compose a feature + +If multi-domain logic grows its own models, screens, or lifecycle, create a feature for it. Read `references/ffca/overview.md`, sections *Features* and *Presentation-only features*. A feature that composes several other domains into a screen without owning business logic is a presentation-only feature: a `{name}_presentation` package with no domain or data sibling, depending on the other features' domains. ## Identity versus entity -Auth and user profile are separate domains, not one. Read the FAQ entry *How do I handle Auth and User Profiles?* Glue them with a query in the consuming feature's domain so a new profile field never forces a change to auth. +Auth and user profile are separate domains, not one. Read `references/ffca/faq.md`, section *How do I handle auth and user profiles?* Keep `AuthUser` in `auth_domain` and `UserProfile` in `user_profile_domain`, and glue them with a Query in the consuming domain, so a new profile field never forces a change to auth. diff --git a/skills/ffca-feature/SKILL.md b/skills/ffca-feature/SKILL.md index b1ab24b..16c795d 100644 --- a/skills/ffca-feature/SKILL.md +++ b/skills/ffca-feature/SKILL.md @@ -1,20 +1,20 @@ --- name: ffca-feature -description: "Scaffold and extend FFCA features: the three-package domain/data/presentation structure, models, repositories, use cases, DTOs, mappers, Cubits, and Modules." -when_to_use: Use when creating a new feature, headless feature, screen, or adding a layer to an existing feature in an FFCA monorepo. Covers the three-package scaffold, domain models, repositories, use cases, DTOs, mappers, Cubits, and Modules. +description: "Scaffold and extend FFCA features: the three-package domain/data/presentation structure, models, repositories, Commands and Queries, DTOs, mappers, Cubits, and Modules." +when_to_use: Use when creating a new feature, headless feature, presentation-only feature, screen, or adding a layer to an existing feature in an FFCA monorepo. Covers the three-package scaffold, domain models, repositories, Commands and Queries, DTOs, mappers, Cubits, and Modules. allowed-tools: Read Glob Grep Write Edit mcp__very_good_cli__create mcp__very_good_cli__packages_get effort: high --- # FFCA Feature -Workflow for building a feature in an FFCA monorepo. Conventions and code shapes are not restated here: read the cited sections of `references/ffca_architecture.md` and adapt the templates in `references/code_templates/`. +Workflow for building a feature in an FFCA monorepo. Conventions and code shapes are not restated here: read the cited sections of `${CLAUDE_PLUGIN_ROOT}/references/ffca/` and adapt the templates in `${CLAUDE_PLUGIN_ROOT}/references/code_templates/`. ## Before you start 1. Confirm the repo is FFCA-shaped (a `features/` folder with `{feature}_{layer}` packages). If not, stop and use the layered-architecture skill. -2. Read `references/ffca_architecture.md`, section *Feature Layers*, for the layer model, and section *Naming Conventions (Enforced)* for package names (the hook validates them). -3. Check for a pre-generated API client first. If the backend ships one OpenAPI/Swagger definition, read the FAQ entry *What if the backend has one large OpenAPI / Swagger definition for all endpoints?* The client belongs in `shared/`, and each feature's data layer consumes it. +2. Read `references/ffca/overview.md`, section *Feature layers*, for the layer model, and `references/ffca/project_structure.md`, section *Naming conventions*, for package names (the hook validates them). +3. Check for a pre-generated API client first. If the backend ships one OpenAPI/Swagger definition, read `references/ffca/faq.md`, section *What if the backend has one large OpenAPI or Swagger definition for every endpoint?* The client belongs in `shared/`, and each feature's data layer consumes it. ## Build order: domain, then data, then presentation @@ -22,38 +22,46 @@ Scaffold each package with the Very Good CLI (`dart_package` for domain and data ### 1. Domain (`{feature}_domain`, Dart package) -Read section *Domain Layer* and adapt `references/code_templates/domain_templates.md`: +Read `references/ffca/domain.md` and adapt `references/code_templates/domain_templates.md`: - Models in `models/`: plain Dart with value equality. Do not suffix them with `Model` or `Entity`. - Repository interfaces in `repositories/`: `abstract interface class`, one per feature, named for the feature's own aggregate. A feature's domain never defines another feature's repository. -- Use cases in `use_cases/`: add a `Query` or `Command` class only when you combine multiple repositories or repeat the same work across Blocs. A passthrough to a single repository does not need one. Verbs: `execute` for commands, `get`/`watch` for queries. +- Commands and Queries in `use_cases/`: add one only when you combine multiple repositories or repeat the same work across Blocs. A pass-through to a single repository does not need a class at all. Name them `{Verb}{Thing}Command` and `{Get,Watch}{Thing}Query`, and use `execute` for commands, `get` or `watch` for queries. Do not call these "use cases" in names or docs; the folder keeps that name, the classes do not. See section *Business rules* and `references/ffca/faq.md`, section *Should we use callable classes for Commands and Queries?* - Add the primary barrel `{feature}_domain.dart`. ### 2. Data (`{feature}_data`, Dart package) -Read section *Data Layer* and adapt `references/code_templates/data_templates.md`: +Read `references/ffca/data.md` and adapt `references/code_templates/data_templates.md`: -- Data sources in `data_sources/`, each with a `dtos/` subfolder. Do not leak DTOs to other layers. -- Mappers in `mappers/`: extension methods (for example `toDomain()`) converting DTOs to domain models. -- Repository implementations in `repositories/` that fulfil the domain interface. +- Data sources in `data_sources/`, each with a `dtos/` subfolder. A DTO never escapes the package. +- Mappers in `mappers/`. Prefer a `Converter` subclass, which keeps the mapping in one named, testable place; extension methods such as `toDomain()` and generators like `auto_mappr` are also fine. One converter per source and per direction, so a feature reading from a database and an API has a `DbToDomain...` and an `ApiToDomain...`. +- Repository implementations in `repositories/` that fulfil the domain interface and apply the mapping as data comes out of the source. - Add the primary barrel `{feature}_data.dart`. ### 3. Presentation (`{feature}_presentation`, Flutter package) -Read section *Presentation Layer* and adapt `references/code_templates/presentation_templates.md`: +Read `references/ffca/presentation.md` and adapt `references/code_templates/presentation_templates.md`: -- Per screen: a `{screen}/bloc/` Cubit with sealed states (Initial, Loading, Loaded, Error), a `{screen}/views/` screen that switches on state, and a `{screen}/{screen}_module.dart` that wires Providers and exposes navigation callbacks. -- Add subfeature barrels per independent entry point plus the primary barrel `{feature}_presentation.dart`. Read section *Subfeature Barrel Files* for why this matters to deferred imports. +- Per screen: a `{screen}/bloc/` Cubit with sealed states (Initial, Loading, Loaded, Error), a `{screen}/views/` screen that switches on state, and a `{screen}/{screen}_module.dart` that wires dependencies and exposes navigation callbacks. +- The module constructs its dependencies with `Provider`. Read `references/ffca/presentation.md`, section *The module*: standardizing on one DI package is deliberate, so that every module in every feature reads the same way. +- Add subfeature barrels per independent entry point plus the primary barrel `{feature}_presentation.dart`. Read `references/ffca/presentation.md`, section *Subfeature barrel files*, for why this matters to deferred imports. - For navigation wiring, use the `ffca-routing` skill. -## Headless features +## Feature shapes other than all three layers -A feature with no UI is domain plus data only. Read section *Headless Features*. When a screen is needed later, add a `{feature}_presentation` package: no change to the existing packages. If the data layer is backend-specific, name it `{feature}_data_{backend}` (for example `auth_data_firebase`). +- **Headless feature**: domain plus data, no UI. Read `references/ffca/overview.md`, section *Headless features*. When a screen is needed later, add a `{feature}_presentation` package with no change to the existing packages. If the data layer is backend-specific, name it `{feature}_data_{backend}`, for example `auth_data_firebase`. +- **Presentation-only feature**: a presentation package and nothing else, existing to compose other features' domains into a screen. Read `references/ffca/overview.md`, section *Presentation-only features*. The layout and naming do not change: it sits at `features/{name}/{name}_presentation` like any other presentation package, and the absence of siblings is the signal. Nothing needs declaring. + +## Reserving a slot for a widget you do not own + +When a screen needs to display something owned by another feature, the module can declare a slot the app fills, instead of importing that feature. Give the parameter a nullable `Widget` type, default it to nothing, and name it for its position (`trailingAction`), never for the widget you expect (`cartBadgeBuilder` leaks the other feature straight back in). More than two slots on one module means the composition belongs in an app-owned shell. Read `references/ffca/presentation.md`, section *Or let the app place it*, and use the `ffca-cross-feature` skill to choose between a slot and a direct import. ## Pubspecs and dependencies -When you add path dependencies, follow section *Dependency Graph Rules*. The pubspec hook blocks edits that violate a layer rule and tells you the fix, so set the direction correctly the first time: data depends on its own domain, presentation depends on domains (never a data layer), neither depends on an app. +When you add path dependencies, follow `references/ffca/project_structure.md`, section *Dependency rules*. The pubspec hook blocks edits that violate a layer rule and tells you the fix, so set the direction correctly the first time: data depends on its own domain, presentation depends on domains and never on a data layer, neither depends on an app. + +If the app defers this feature's import, read `references/ffca/project_structure.md`, section *Deferred loading*, before adding a dependency on another feature's presentation package. An eager edge from a deferred package silently cancels the code splitting downstream. ## Tests -Every package gets tests. This skill does not cover test mechanics: delegate `blocTest`, `mocktail`, and golden details to the vgv-ai-flutter-plugin testing skill. Cover each repository implementation, each use case, and each Cubit (success, failure, edge cases). +Every package gets tests. This skill does not cover test mechanics: delegate `blocTest`, `mocktail`, and golden details to the vgv-ai-flutter-plugin testing skill. Cover each repository implementation, each Command and Query, and each Cubit (success, failure, edge cases). A module whose slots default to nothing stays golden-testable without the other feature's repositories in the widget tree. diff --git a/skills/ffca-routing/SKILL.md b/skills/ffca-routing/SKILL.md index 97e6a90..315dbec 100644 --- a/skills/ffca-routing/SKILL.md +++ b/skills/ffca-routing/SKILL.md @@ -1,34 +1,70 @@ --- name: ffca-routing -description: "Routing and navigation for FFCA monorepos: callback injection, go_router_builder typed routes, the $extra hydration pattern, and feature isolation." -when_to_use: Use when adding screens, routes, navigation, deep links, or navigation callbacks in an FFCA monorepo. +description: "Routing and navigation for FFCA monorepos: callback injection, go_router_builder typed routes, splitting the routing table, the $extra hydration pattern, and feature isolation." +when_to_use: Use when adding screens, routes, navigation, deep links, navigation callbacks, or a deferred-loaded feature in an FFCA monorepo. allowed-tools: Read Glob Grep Write Edit effort: high --- # FFCA Routing -Workflow for wiring navigation in an FFCA monorepo. The patterns and code shapes live in `references/ffca_architecture.md`, section *Routing & Navigation*, and in `references/code_templates/presentation_templates.md`. Read them before wiring routes. +Workflow for wiring navigation in an FFCA monorepo. The patterns and code shapes live in `${CLAUDE_PLUGIN_ROOT}/references/ffca/navigation.md` and in `${CLAUDE_PLUGIN_ROOT}/references/code_templates/presentation_templates.md`. Read them before wiring routes. ## The core constraint -Features are isolated. A feature never imports the app's router or another feature's routes. Navigation is a dependency the app layer injects. Read section *Routing & Navigation* for the rationale, then follow the pattern that fits the feature's depth. +Features are isolated, so a feature cannot know the app's global routing table. It never imports the app's router or another feature's routes. Navigation is a dependency the app layer injects: the app decides *what happens next*, the feature only detects *when* it is needed. Read the intro of `references/ffca/navigation.md`, then follow the pattern that fits the feature's depth. ## Choose the injection pattern -1. **Shallow widget trees: callbacks on the Module.** The Module constructor takes typed callbacks (`onProductSelected`, `onCheckoutRequested`), and the app layer supplies them. Adapt the Module example in `references/code_templates/presentation_templates.md`. -2. **Deep widget trees: a Navigation class.** Define a navigation contract in the feature's presentation package and provide it to the subtree, so deep widgets invoke callbacks without prop drilling. See the `CartNavigation` example in section *Routing & Navigation*. -3. **Alternative: Actions and Intents.** Register Actions at the top of the tree and invoke typed Intents from deep widgets. Same trade-off as Provider (resolved at runtime). The Module-boundary callback stays the compile-time-safe cross-feature contract either way. +1. **Shallow widget trees: callbacks on the module.** The module constructor takes typed callbacks (`onProductTapped`, `onCheckoutStarted`) and the app supplies them. Read `references/ffca/navigation.md`, section *Simple features: pass callbacks directly*, and adapt the module example in `references/code_templates/presentation_templates.md`. +2. **Deep widget trees: a navigation interface.** Define a plain Dart class in the feature's presentation package holding every navigation action, require it in the module, and provide it to the subtree with `Provider.value`. Deep widgets then call `context.read().onProductTapped(id)` with no prop drilling. Read `references/ffca/navigation.md`, section *Deep trees: define a navigation interface*. + +Whichever you pick, the callback at the module boundary is the compile-time-safe cross-feature contract. ## Wire routes in the app layer with go_router_builder -The recommended implementation is `go_router` plus `go_router_builder`. The app layer defines `@TypedGoRoute` and `GoRouteData` classes that instantiate feature Modules and wire their callbacks to typed route navigation. Adapt the `GoRouteData` template in `references/code_templates/presentation_templates.md`. +Use `go_router` with `go_router_builder`. The app defines `@TypedGoRoute` and `GoRouteData` classes that instantiate feature modules and wire their callbacks to typed route navigation. Adapt the `GoRouteData` template in `references/code_templates/presentation_templates.md`. -Two hard rules from section *Routing & Navigation*: +Two hard rules from `references/ffca/navigation.md`, section *Recommended: go_router with go_router_builder*: -- No string-based paths. Navigate with generated route classes, for example `ProductDetailRoute(id: id).go(context)`, never `context.push('/product/123')`. +- No string-based paths. Navigate with generated route classes, `ProductDetailRoute(id: id).go(context)`, never `context.push('/product/123')`. A string path fails at runtime instead of compile time, and it forces the feature to know the app's URL structure. - Features never import the app router configuration or another feature's route classes. +## Split the routing table, but keep it one library + +The app owns the routing table, so every feature lands in the same file and it becomes a merge-conflict magnet. Give each feature's routes their own file, with one constraint that is easy to get wrong. + +`go_router_builder` collects every `@TypedGoRoute` it finds in a *library* into a single generated `$appRoutes`. Routes declared in a separate library are left out, **and the failure is quiet**: the build succeeds and the routes simply do not exist. So the per-feature files must be `part` of one library, not separate imports. + +```text +apps/my_app/lib/app_router/ + routes.dart the library: imports, part directives, root route + favorites_routes.dart part + ideas_routes.dart part + routes.g.dart generated part +``` + +`routes.dart` holds every import and every `part` directive; each feature file starts with `part of 'routes.dart';`. Adding a feature is two lines in the shared file plus a new file nobody else is editing. Read `references/ffca/navigation.md`, section *Splitting the routing table across files*. + +## Deferred imports live in the routing table + +Because all imports are centralized in `routes.dart`, that is also where each feature gets its `deferred as` prefix, so its code is fetched the first time a route needs it rather than shipping in the initial bundle. Import a subfeature barrel rather than the whole package where one exists, so opening a list screen does not also download the detail screen. + +```dart +import 'package:favorites_presentation/favorites_list.dart' + deferred as favorites_list; +``` + +Before relying on this, read `references/ffca/project_structure.md`, section *Deferred loading*. A deferred package must not also be reachable from the app through a non-deferred path, or the chunk never splits, silently. This is informational on an iOS/Android-only app, where the AOT snapshot contains the whole program regardless. + ## Deep links and the $extra pattern -Every screen must be rebuildable from its URL parameters alone. `$extra` is a volatile optimization that is lost on refresh, process death, and cold-start deep links, so the route passes it as nullable and the Bloc treats it as optional: emit `Loaded` immediately when it is present, otherwise fetch by id and show loading. Adapt the `ProductDetailRoute` template and read the *Data hydration for deep links* part of section *Routing & Navigation*. +Every screen must be rebuildable from its URL parameters alone. `$extra` is a volatile optimization that is lost on browser refresh, process death, and cold-start deep links, so the route declares it nullable and the Bloc treats it as optional: emit `Loaded` immediately when it is present, otherwise fetch by id and show loading. Read `references/ffca/navigation.md`, section *Deep links and data hydration*, and adapt the `ProductDetailRoute` template. + +## Visual extension points + +The same inversion applies to widgets. A feature can declare a slot for a widget it does not own and let the app fill it, exactly as it declares a callback for a destination it does not own. Read `references/ffca/navigation.md`, section *Visual extension points*, then use the `ffca-cross-feature` skill to choose between a slot and a direct import. + +## Add-to-app + +A module is a self-contained entry point with explicit dependencies, so it can be instantiated directly as the root widget of a `FlutterEngine` instead of inside a `GoRouteData.build()`. The module code is identical; only the callback targets change, from go_router routes to platform channels. Read `references/ffca/project_structure.md`, section *Add-to-app support*. From 05921ea077e1eaaafba0763dad20b61a8410f9d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9my=20Baudet?= Date: Thu, 24 Sep 2026 10:33:59 -0600 Subject: [PATCH 2/8] fix: quote plugin root in the layer validation hook command claude plugin validate warned that an unquoted ${CLAUDE_PLUGIN_ROOT} splits into several words when the install path contains a space. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01X1XbE7ufv8YHpJLV4MTZGQ --- hooks/hooks.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/hooks/hooks.json b/hooks/hooks.json index 33a3408..c3b01bd 100644 --- a/hooks/hooks.json +++ b/hooks/hooks.json @@ -7,7 +7,7 @@ "hooks": [ { "type": "command", - "command": "bash ${CLAUDE_PLUGIN_ROOT}/hooks/validate_layers.sh", + "command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/validate_layers.sh\"", "timeout": 30 } ] From 02d220760f2f3fb1bb2c4291ca0d6bc2ab8055b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9my=20Baudet?= Date: Thu, 24 Sep 2026 10:50:19 -0600 Subject: [PATCH 3/8] docs: split the install steps so they are not run as one shell block The two slash commands were shown in a single bash block, which reads as something to paste at once. Inside a Claude Code session the second only works after the first completes, so present them as separate steps and add the terminal one-liner, matching the sibling plugin READMEs. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01X1XbE7ufv8YHpJLV4MTZGQ --- README.md | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 1e33db8..eef092f 100644 --- a/README.md +++ b/README.md @@ -37,13 +37,28 @@ The `ffca-architecture` skill defers to `layered-architecture` when it sees the ## Installation -The plugin is published in the [Very Good Claude Marketplace](https://github.com/VeryGoodOpenSource/very-good-claude-code-marketplace). Inside Claude: +The plugin is published in the [Very Good Claude Marketplace](https://github.com/VeryGoodOpenSource/very-good-claude-code-marketplace). + +One-line install from your terminal: ```bash -/plugin marketplace add VeryGoodOpenSource/very-good-claude-code-marketplace -/plugin install vgv-ffca-plugin +claude plugin marketplace add VeryGoodOpenSource/very-good-claude-code-marketplace && claude plugin install vgv-ffca-plugin ``` +Or inside an active Claude Code session, run these as **two separate commands** (the second only after the first completes): + +1. Add the marketplace: + + ```text + /plugin marketplace add VeryGoodOpenSource/very-good-claude-code-marketplace + ``` + +2. Install the plugin: + + ```text + /plugin install vgv-ffca-plugin + ``` + ## Skills | Skill | Description | From bac3f05d364f1009d8251412e009e727bad52fdf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9my=20Baudet?= Date: Thu, 24 Sep 2026 10:59:11 -0600 Subject: [PATCH 4/8] docs: link the FFCA reference on VGV Engineering from the intro Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01X1XbE7ufv8YHpJLV4MTZGQ --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index eef092f..cdf573c 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # VGV FFCA Plugin -A [Claude Code](https://claude.com/claude-code) plugin that operationalizes Feature-First Clean Architecture (FFCA) for Flutter monorepos. +A [Claude Code](https://claude.com/claude-code) plugin that operationalizes [Feature-First Clean Architecture (FFCA)](https://engineering.verygood.ventures/architecture/ffca/overview/) for Flutter monorepos. The canonical FFCA reference lives on VGV Engineering, and the plugin teaches Claude those conventions and enforces its layer rules as you work. Developed with 💙 by [Very Good Ventures](https://verygood.ventures) 🦄 From c0f2ddb9ff8e042442b7178ec829a9c255d3400d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9my=20Baudet?= Date: Thu, 24 Sep 2026 10:59:14 -0600 Subject: [PATCH 5/8] docs: point the reference section at a page that resolves The FFCA section root returns 404 upstream. The overview page is the landing URL. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01X1XbE7ufv8YHpJLV4MTZGQ --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index fe72beb..53ed3d6 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ The conventions themselves live in `references/ffca/`, a byte mirror of the cano ## The architecture reference -`references/ffca/` holds one file per page of the canonical documentation, fetched verbatim from : +`references/ffca/` holds one file per page of the canonical documentation, fetched verbatim from the [FFCA section of VGV Engineering](https://engineering.verygood.ventures/architecture/ffca/overview/): | File | Covers | | --- | --- | From 4cdadd1a2ee49b488b05e4f18ba6aab32d3e0455 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9my=20Baudet?= Date: Thu, 24 Sep 2026 10:59:57 -0600 Subject: [PATCH 6/8] docs: drop the intro sentence that duplicated the overview Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01X1XbE7ufv8YHpJLV4MTZGQ --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index cdf573c..767699d 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # VGV FFCA Plugin -A [Claude Code](https://claude.com/claude-code) plugin that operationalizes [Feature-First Clean Architecture (FFCA)](https://engineering.verygood.ventures/architecture/ffca/overview/) for Flutter monorepos. The canonical FFCA reference lives on VGV Engineering, and the plugin teaches Claude those conventions and enforces its layer rules as you work. +A [Claude Code](https://claude.com/claude-code) plugin that operationalizes [Feature-First Clean Architecture (FFCA)](https://engineering.verygood.ventures/architecture/ffca/overview/) for Flutter monorepos. Developed with 💙 by [Very Good Ventures](https://verygood.ventures) 🦄 From 75c3586d616512e5f4dece3c3744e4b435aceb1e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9my=20Baudet?= Date: Tue, 29 Sep 2026 08:51:31 -0600 Subject: [PATCH 7/8] docs: drop the VGV Engineering URL from the auditor and skill The mirror in references/ffca/ is what the model should read. A live URL invites it to fetch the page instead, which adds noise and can drift from the pinned copy. The READMEs keep the link for humans. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017Vxxv9fVFGupLXEnmx5bSq --- agents/ffca-layer-auditor.md | 2 +- skills/ffca-architecture/SKILL.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/agents/ffca-layer-auditor.md b/agents/ffca-layer-auditor.md index ff22eed..ecbfe63 100644 --- a/agents/ffca-layer-auditor.md +++ b/agents/ffca-layer-auditor.md @@ -29,7 +29,7 @@ model: inherit You audit a Feature-First Clean Architecture (FFCA) monorepo and report whether it is healthy. You are read-only: you report violations with their fix, you never edit code. -Do not restate the conventions from memory. The rules live in `${CLAUDE_PLUGIN_ROOT}/references/ffca/`, a byte mirror of ; read the cited file and section whenever you need the detail behind a check. If the repo has no `features/` folder, it is not FFCA-shaped: say so and stop. +Do not restate the conventions from memory. The rules live in `${CLAUDE_PLUGIN_ROOT}/references/ffca/`; read the cited file and section whenever you need the detail behind a check. If the repo has no `features/` folder, it is not FFCA-shaped: say so and stop. ## Step 1: mechanical pass (deterministic) diff --git a/skills/ffca-architecture/SKILL.md b/skills/ffca-architecture/SKILL.md index e618e19..3e7995c 100644 --- a/skills/ffca-architecture/SKILL.md +++ b/skills/ffca-architecture/SKILL.md @@ -8,7 +8,7 @@ effort: high # FFCA Architecture -Orientation skill for Feature-First Clean Architecture (FFCA) monorepos. It tells you how to navigate the structure and which rules apply. It does not restate the conventions: those live in `${CLAUDE_PLUGIN_ROOT}/references/ffca/`, a byte mirror of the canonical docs at . Every step below names the file and section to read. +Orientation skill for Feature-First Clean Architecture (FFCA) monorepos. It tells you how to navigate the structure and which rules apply. It does not restate the conventions: those live in `${CLAUDE_PLUGIN_ROOT}/references/ffca/`. Every step below names the file and section to read. ## Confirm you are in an FFCA repo From 30e77897ba7a9e19e3f6f3f8a52726d70db5b65b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9my=20Baudet?= Date: Tue, 29 Sep 2026 08:55:30 -0600 Subject: [PATCH 8/8] fix: resolve workspace dependencies by name in the layer validator The validator only followed `path:` dependencies. In a Dart workspace, which the FFCA docs recommend, members depend on each other by name (`cart_domain: any`) with no path, so the validator saw no edges and passed every layer violation. It now resolves a dependency without a path to the workspace package of that name. Both fixtures are now real pub workspaces with `resolution: workspace`, each keeping one `path:` edge so both forms stay covered. The auditor and skills no longer describe dependencies as path-only. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017Vxxv9fVFGupLXEnmx5bSq --- agents/ffca-layer-auditor.md | 4 +- .../apps/mobile_app/pubspec.yaml | 1 + .../features/alpha/alpha_data/pubspec.yaml | 1 + .../features/alpha/alpha_domain/pubspec.yaml | 4 +- .../features/beta/beta_data/pubspec.yaml | 4 +- .../features/beta/beta_domain/pubspec.yaml | 1 + .../beta/beta_presentation/pubspec.yaml | 1 + .../features/delta/delta_data/pubspec.yaml | 4 +- .../features/delta/delta_domain/pubspec.yaml | 1 + .../epsilon/epsilon_domain/pubspec.yaml | 4 +- .../features/eta/eta_data/pubspec.yaml | 7 +- .../features/eta/eta_domain/pubspec.yaml | 1 + .../features/gamma/gamma_domain/pubspec.yaml | 1 + .../gamma/gamma_presentation/pubspec.yaml | 7 +- .../orders/orders_service/pubspec.yaml | 1 + .../features/theta/theta_data/pubspec.yaml | 1 + .../features/zeta/zeta_domain/pubspec.yaml | 4 +- .../fixtures/invalid_workspace/pubspec.yaml | 18 ++++ .../shared/bad_shared/pubspec.yaml | 4 +- .../apps/mobile_app/pubspec.yaml | 16 ++-- .../auth/auth_data_firebase/pubspec.yaml | 1 + .../features/auth/auth_domain/pubspec.yaml | 1 + .../features/cart/cart_data/pubspec.yaml | 4 +- .../features/cart/cart_domain/pubspec.yaml | 4 +- .../cart/cart_presentation/pubspec.yaml | 10 +- .../ideas/ideas_presentation/pubspec.yaml | 13 +-- .../product/product_data/pubspec.yaml | 4 +- .../product/product_domain/pubspec.yaml | 1 + .../product/product_presentation/pubspec.yaml | 7 +- .../fixtures/valid_workspace/pubspec.yaml | 13 +++ .../shared/api_client/pubspec.yaml | 1 + .../shared/ui_kit/pubspec.yaml | 1 + scripts/test/validate_layers_test.dart | 7 ++ scripts/validate_layers.dart | 95 +++++++++++++------ skills/ffca-architecture/SKILL.md | 2 +- skills/ffca-feature/SKILL.md | 2 +- 36 files changed, 163 insertions(+), 88 deletions(-) diff --git a/agents/ffca-layer-auditor.md b/agents/ffca-layer-auditor.md index ecbfe63..199898d 100644 --- a/agents/ffca-layer-auditor.md +++ b/agents/ffca-layer-auditor.md @@ -13,7 +13,7 @@ description: | - Context: The user is about to open a PR that adds several path dependencies. + Context: The user is about to open a PR that adds several dependencies between packages. user: "Before I open the PR, can you check the whole repo follows FFCA?" assistant: "I'll dispatch the ffca-layer-auditor agent for a full-graph audit and a per-package verdict table." @@ -46,7 +46,7 @@ This is the authoritative check for the pubspec dependency graph: package naming The script validates the declared pubspec graph. These checks need the source, so do them by reading and grepping the tree. Cite the reference section for each. 1. **Package inventory and classification.** Enumerate every package under `apps/`, `features/`, `shared/`. Classify each by folder, name, and inferred type: full feature, headless feature (domain plus data, no presentation), presentation-only feature (a `_presentation` with no domain or data sibling), or shared package. All four are legitimate; a missing sibling is a signal, not a violation. See `references/ffca/overview.md`, sections *Features*, *Headless features*, *Presentation-only features*, *Shared libraries*. -2. **Declared-but-unused dependencies.** For each package, every path dependency in its pubspec should be imported somewhere in its source. A declared dependency that is never imported is a finding. Pay special attention to presentation packages. +2. **Declared-but-unused dependencies.** For each package, every dependency on another workspace package in its pubspec, by `path:` or by name, should be imported somewhere in its source. A declared dependency that is never imported is a finding. Pay special attention to presentation packages. 3. **Barrel hygiene.** Each package has a primary barrel `lib/{package}.dart` re-exporting `src/` (or the layer's public files). Features with multiple independent entry points have subfeature barrels. No barrel re-exports a private symbol. Detect private re-exports with `rg -n "^export '.*/_" features shared apps`. A widget exported for another feature has its own narrow barrel whose transitive imports do not reach the feature's modules or screens; a cross-feature import that targets the primary barrel is a finding. See `references/ffca/presentation.md`, sections *Subfeature barrel files* and *Sharing a widget across features*, and `references/ffca/project_structure.md`, section *Layer subfolders*. 4. **DTO leakage.** Generated or transport types (`*.g.dart`, `*.freezed.dart`, anything under a `dtos/` folder) must not be imported outside the data package that owns them. Detect with `rg -n "import .*\.(g|freezed)\.dart'" features shared apps` and check the importing package owns the source. See `references/ffca/data.md`, section *DTOs and mappers*. 5. **Command and Query necessity.** A `Query` or `Command` class in a `*_domain` package is justified only when it combines two or more repositories or removes duplication across Blocs. A single-repository pass-through is an anti-pattern. Open each `*_query.dart` / `*_command.dart` and count injected repositories. Also check the verbs: commands expose `execute`, queries expose `get` or `watch`, and neither is a callable class. Classes named `*UseCase` are a naming finding. See `references/ffca/domain.md`, section *Business rules*, and `references/ffca/faq.md`, section *Should we use callable classes for Commands and Queries?* diff --git a/scripts/test/fixtures/invalid_workspace/apps/mobile_app/pubspec.yaml b/scripts/test/fixtures/invalid_workspace/apps/mobile_app/pubspec.yaml index c272476..e179287 100644 --- a/scripts/test/fixtures/invalid_workspace/apps/mobile_app/pubspec.yaml +++ b/scripts/test/fixtures/invalid_workspace/apps/mobile_app/pubspec.yaml @@ -1,3 +1,4 @@ name: mobile_app +resolution: workspace environment: sdk: ^3.11.0 diff --git a/scripts/test/fixtures/invalid_workspace/features/alpha/alpha_data/pubspec.yaml b/scripts/test/fixtures/invalid_workspace/features/alpha/alpha_data/pubspec.yaml index 3ee7785..0e0c476 100644 --- a/scripts/test/fixtures/invalid_workspace/features/alpha/alpha_data/pubspec.yaml +++ b/scripts/test/fixtures/invalid_workspace/features/alpha/alpha_data/pubspec.yaml @@ -1,3 +1,4 @@ name: alpha_data +resolution: workspace environment: sdk: ^3.11.0 diff --git a/scripts/test/fixtures/invalid_workspace/features/alpha/alpha_domain/pubspec.yaml b/scripts/test/fixtures/invalid_workspace/features/alpha/alpha_domain/pubspec.yaml index 7f623b7..9d5873d 100644 --- a/scripts/test/fixtures/invalid_workspace/features/alpha/alpha_domain/pubspec.yaml +++ b/scripts/test/fixtures/invalid_workspace/features/alpha/alpha_domain/pubspec.yaml @@ -1,6 +1,6 @@ name: alpha_domain +resolution: workspace environment: sdk: ^3.11.0 dependencies: - alpha_data: - path: ../alpha_data + alpha_data: any diff --git a/scripts/test/fixtures/invalid_workspace/features/beta/beta_data/pubspec.yaml b/scripts/test/fixtures/invalid_workspace/features/beta/beta_data/pubspec.yaml index 692cc0d..cc46698 100644 --- a/scripts/test/fixtures/invalid_workspace/features/beta/beta_data/pubspec.yaml +++ b/scripts/test/fixtures/invalid_workspace/features/beta/beta_data/pubspec.yaml @@ -1,6 +1,6 @@ name: beta_data +resolution: workspace environment: sdk: ^3.11.0 dependencies: - beta_presentation: - path: ../beta_presentation + beta_presentation: any diff --git a/scripts/test/fixtures/invalid_workspace/features/beta/beta_domain/pubspec.yaml b/scripts/test/fixtures/invalid_workspace/features/beta/beta_domain/pubspec.yaml index 4e77dbc..cf7a446 100644 --- a/scripts/test/fixtures/invalid_workspace/features/beta/beta_domain/pubspec.yaml +++ b/scripts/test/fixtures/invalid_workspace/features/beta/beta_domain/pubspec.yaml @@ -1,3 +1,4 @@ name: beta_domain +resolution: workspace environment: sdk: ^3.11.0 diff --git a/scripts/test/fixtures/invalid_workspace/features/beta/beta_presentation/pubspec.yaml b/scripts/test/fixtures/invalid_workspace/features/beta/beta_presentation/pubspec.yaml index 4a58458..9aeab72 100644 --- a/scripts/test/fixtures/invalid_workspace/features/beta/beta_presentation/pubspec.yaml +++ b/scripts/test/fixtures/invalid_workspace/features/beta/beta_presentation/pubspec.yaml @@ -1,3 +1,4 @@ name: beta_presentation +resolution: workspace environment: sdk: ^3.11.0 diff --git a/scripts/test/fixtures/invalid_workspace/features/delta/delta_data/pubspec.yaml b/scripts/test/fixtures/invalid_workspace/features/delta/delta_data/pubspec.yaml index b3b9a55..bc71ae3 100644 --- a/scripts/test/fixtures/invalid_workspace/features/delta/delta_data/pubspec.yaml +++ b/scripts/test/fixtures/invalid_workspace/features/delta/delta_data/pubspec.yaml @@ -1,6 +1,6 @@ name: delta_data +resolution: workspace environment: sdk: ^3.11.0 dependencies: - delta_domain: - path: ../delta_domain + delta_domain: any diff --git a/scripts/test/fixtures/invalid_workspace/features/delta/delta_domain/pubspec.yaml b/scripts/test/fixtures/invalid_workspace/features/delta/delta_domain/pubspec.yaml index e987842..8046e38 100644 --- a/scripts/test/fixtures/invalid_workspace/features/delta/delta_domain/pubspec.yaml +++ b/scripts/test/fixtures/invalid_workspace/features/delta/delta_domain/pubspec.yaml @@ -1,3 +1,4 @@ name: delta_domain +resolution: workspace environment: sdk: ^3.11.0 diff --git a/scripts/test/fixtures/invalid_workspace/features/epsilon/epsilon_domain/pubspec.yaml b/scripts/test/fixtures/invalid_workspace/features/epsilon/epsilon_domain/pubspec.yaml index 9ae97fa..96dca7d 100644 --- a/scripts/test/fixtures/invalid_workspace/features/epsilon/epsilon_domain/pubspec.yaml +++ b/scripts/test/fixtures/invalid_workspace/features/epsilon/epsilon_domain/pubspec.yaml @@ -1,6 +1,6 @@ name: epsilon_domain +resolution: workspace environment: sdk: ^3.11.0 dependencies: - zeta_domain: - path: ../../zeta/zeta_domain + zeta_domain: any diff --git a/scripts/test/fixtures/invalid_workspace/features/eta/eta_data/pubspec.yaml b/scripts/test/fixtures/invalid_workspace/features/eta/eta_data/pubspec.yaml index 95157f0..25a49e6 100644 --- a/scripts/test/fixtures/invalid_workspace/features/eta/eta_data/pubspec.yaml +++ b/scripts/test/fixtures/invalid_workspace/features/eta/eta_data/pubspec.yaml @@ -1,8 +1,7 @@ name: eta_data +resolution: workspace environment: sdk: ^3.11.0 dependencies: - eta_domain: - path: ../eta_domain - mobile_app: - path: ../../../apps/mobile_app + eta_domain: any + mobile_app: any diff --git a/scripts/test/fixtures/invalid_workspace/features/eta/eta_domain/pubspec.yaml b/scripts/test/fixtures/invalid_workspace/features/eta/eta_domain/pubspec.yaml index ce99a84..03625d4 100644 --- a/scripts/test/fixtures/invalid_workspace/features/eta/eta_domain/pubspec.yaml +++ b/scripts/test/fixtures/invalid_workspace/features/eta/eta_domain/pubspec.yaml @@ -1,3 +1,4 @@ name: eta_domain +resolution: workspace environment: sdk: ^3.11.0 diff --git a/scripts/test/fixtures/invalid_workspace/features/gamma/gamma_domain/pubspec.yaml b/scripts/test/fixtures/invalid_workspace/features/gamma/gamma_domain/pubspec.yaml index e14c734..1c22d8e 100644 --- a/scripts/test/fixtures/invalid_workspace/features/gamma/gamma_domain/pubspec.yaml +++ b/scripts/test/fixtures/invalid_workspace/features/gamma/gamma_domain/pubspec.yaml @@ -1,3 +1,4 @@ name: gamma_domain +resolution: workspace environment: sdk: ^3.11.0 diff --git a/scripts/test/fixtures/invalid_workspace/features/gamma/gamma_presentation/pubspec.yaml b/scripts/test/fixtures/invalid_workspace/features/gamma/gamma_presentation/pubspec.yaml index 7d6f3de..5934847 100644 --- a/scripts/test/fixtures/invalid_workspace/features/gamma/gamma_presentation/pubspec.yaml +++ b/scripts/test/fixtures/invalid_workspace/features/gamma/gamma_presentation/pubspec.yaml @@ -1,8 +1,7 @@ name: gamma_presentation +resolution: workspace environment: sdk: ^3.11.0 dependencies: - gamma_domain: - path: ../gamma_domain - delta_data: - path: ../../delta/delta_data + gamma_domain: any + delta_data: any diff --git a/scripts/test/fixtures/invalid_workspace/features/orders/orders_service/pubspec.yaml b/scripts/test/fixtures/invalid_workspace/features/orders/orders_service/pubspec.yaml index d575bd8..06bcf42 100644 --- a/scripts/test/fixtures/invalid_workspace/features/orders/orders_service/pubspec.yaml +++ b/scripts/test/fixtures/invalid_workspace/features/orders/orders_service/pubspec.yaml @@ -1,3 +1,4 @@ name: orders_service +resolution: workspace environment: sdk: ^3.11.0 diff --git a/scripts/test/fixtures/invalid_workspace/features/theta/theta_data/pubspec.yaml b/scripts/test/fixtures/invalid_workspace/features/theta/theta_data/pubspec.yaml index 65409d6..2e2e415 100644 --- a/scripts/test/fixtures/invalid_workspace/features/theta/theta_data/pubspec.yaml +++ b/scripts/test/fixtures/invalid_workspace/features/theta/theta_data/pubspec.yaml @@ -1,4 +1,5 @@ name: theta_data +resolution: workspace environment: sdk: ^3.11.0 dependencies: diff --git a/scripts/test/fixtures/invalid_workspace/features/zeta/zeta_domain/pubspec.yaml b/scripts/test/fixtures/invalid_workspace/features/zeta/zeta_domain/pubspec.yaml index cacae2a..fe0f7d5 100644 --- a/scripts/test/fixtures/invalid_workspace/features/zeta/zeta_domain/pubspec.yaml +++ b/scripts/test/fixtures/invalid_workspace/features/zeta/zeta_domain/pubspec.yaml @@ -1,6 +1,6 @@ name: zeta_domain +resolution: workspace environment: sdk: ^3.11.0 dependencies: - epsilon_domain: - path: ../../epsilon/epsilon_domain + epsilon_domain: any diff --git a/scripts/test/fixtures/invalid_workspace/pubspec.yaml b/scripts/test/fixtures/invalid_workspace/pubspec.yaml index 33020ff..aab67c2 100644 --- a/scripts/test/fixtures/invalid_workspace/pubspec.yaml +++ b/scripts/test/fixtures/invalid_workspace/pubspec.yaml @@ -1,3 +1,21 @@ name: _invalid_workspace environment: sdk: ^3.11.0 +workspace: + - apps/mobile_app + - features/alpha/alpha_data + - features/alpha/alpha_domain + - features/beta/beta_data + - features/beta/beta_domain + - features/beta/beta_presentation + - features/delta/delta_data + - features/delta/delta_domain + - features/epsilon/epsilon_domain + - features/eta/eta_data + - features/eta/eta_domain + - features/gamma/gamma_domain + - features/gamma/gamma_presentation + - features/orders/orders_service + - features/theta/theta_data + - features/zeta/zeta_domain + - shared/bad_shared diff --git a/scripts/test/fixtures/invalid_workspace/shared/bad_shared/pubspec.yaml b/scripts/test/fixtures/invalid_workspace/shared/bad_shared/pubspec.yaml index aea40b0..2789da8 100644 --- a/scripts/test/fixtures/invalid_workspace/shared/bad_shared/pubspec.yaml +++ b/scripts/test/fixtures/invalid_workspace/shared/bad_shared/pubspec.yaml @@ -1,6 +1,6 @@ name: bad_shared +resolution: workspace environment: sdk: ^3.11.0 dependencies: - alpha_domain: - path: ../../features/alpha/alpha_domain + alpha_domain: any diff --git a/scripts/test/fixtures/valid_workspace/apps/mobile_app/pubspec.yaml b/scripts/test/fixtures/valid_workspace/apps/mobile_app/pubspec.yaml index f0d2e87..8888bc7 100644 --- a/scripts/test/fixtures/valid_workspace/apps/mobile_app/pubspec.yaml +++ b/scripts/test/fixtures/valid_workspace/apps/mobile_app/pubspec.yaml @@ -1,14 +1,10 @@ name: mobile_app +resolution: workspace environment: sdk: ^3.11.0 dependencies: - product_presentation: - path: ../../features/product/product_presentation - cart_presentation: - path: ../../features/cart/cart_presentation - ideas_presentation: - path: ../../features/ideas/ideas_presentation - ui_kit: - path: ../../shared/ui_kit - api_client: - path: ../../shared/api_client + product_presentation: any + cart_presentation: any + ideas_presentation: any + ui_kit: any + api_client: any diff --git a/scripts/test/fixtures/valid_workspace/features/auth/auth_data_firebase/pubspec.yaml b/scripts/test/fixtures/valid_workspace/features/auth/auth_data_firebase/pubspec.yaml index e236412..f5edf3a 100644 --- a/scripts/test/fixtures/valid_workspace/features/auth/auth_data_firebase/pubspec.yaml +++ b/scripts/test/fixtures/valid_workspace/features/auth/auth_data_firebase/pubspec.yaml @@ -1,4 +1,5 @@ name: auth_data_firebase +resolution: workspace environment: sdk: ^3.11.0 dependencies: diff --git a/scripts/test/fixtures/valid_workspace/features/auth/auth_domain/pubspec.yaml b/scripts/test/fixtures/valid_workspace/features/auth/auth_domain/pubspec.yaml index 347eacc..4dbecf6 100644 --- a/scripts/test/fixtures/valid_workspace/features/auth/auth_domain/pubspec.yaml +++ b/scripts/test/fixtures/valid_workspace/features/auth/auth_domain/pubspec.yaml @@ -1,3 +1,4 @@ name: auth_domain +resolution: workspace environment: sdk: ^3.11.0 diff --git a/scripts/test/fixtures/valid_workspace/features/cart/cart_data/pubspec.yaml b/scripts/test/fixtures/valid_workspace/features/cart/cart_data/pubspec.yaml index 2a45ba0..199c153 100644 --- a/scripts/test/fixtures/valid_workspace/features/cart/cart_data/pubspec.yaml +++ b/scripts/test/fixtures/valid_workspace/features/cart/cart_data/pubspec.yaml @@ -1,6 +1,6 @@ name: cart_data +resolution: workspace environment: sdk: ^3.11.0 dependencies: - cart_domain: - path: ../cart_domain + cart_domain: any diff --git a/scripts/test/fixtures/valid_workspace/features/cart/cart_domain/pubspec.yaml b/scripts/test/fixtures/valid_workspace/features/cart/cart_domain/pubspec.yaml index 3f8ed06..100370c 100644 --- a/scripts/test/fixtures/valid_workspace/features/cart/cart_domain/pubspec.yaml +++ b/scripts/test/fixtures/valid_workspace/features/cart/cart_domain/pubspec.yaml @@ -1,6 +1,6 @@ name: cart_domain +resolution: workspace environment: sdk: ^3.11.0 dependencies: - product_domain: - path: ../../product/product_domain + product_domain: any diff --git a/scripts/test/fixtures/valid_workspace/features/cart/cart_presentation/pubspec.yaml b/scripts/test/fixtures/valid_workspace/features/cart/cart_presentation/pubspec.yaml index 221adc1..7486479 100644 --- a/scripts/test/fixtures/valid_workspace/features/cart/cart_presentation/pubspec.yaml +++ b/scripts/test/fixtures/valid_workspace/features/cart/cart_presentation/pubspec.yaml @@ -1,10 +1,8 @@ name: cart_presentation +resolution: workspace environment: sdk: ^3.11.0 dependencies: - cart_domain: - path: ../cart_domain - product_domain: - path: ../../product/product_domain - ui_kit: - path: ../../../shared/ui_kit + cart_domain: any + product_domain: any + ui_kit: any diff --git a/scripts/test/fixtures/valid_workspace/features/ideas/ideas_presentation/pubspec.yaml b/scripts/test/fixtures/valid_workspace/features/ideas/ideas_presentation/pubspec.yaml index 2d533af..50e361d 100644 --- a/scripts/test/fixtures/valid_workspace/features/ideas/ideas_presentation/pubspec.yaml +++ b/scripts/test/fixtures/valid_workspace/features/ideas/ideas_presentation/pubspec.yaml @@ -1,14 +1,11 @@ # A presentation-only feature: it composes other features' domains into a # screen and owns no domain or data of its own, so it has no siblings. name: ideas_presentation +resolution: workspace environment: sdk: ^3.11.0 dependencies: - product_domain: - path: ../../product/product_domain - cart_domain: - path: ../../cart/cart_domain - cart_presentation: - path: ../../cart/cart_presentation - ui_kit: - path: ../../../shared/ui_kit + product_domain: any + cart_domain: any + cart_presentation: any + ui_kit: any diff --git a/scripts/test/fixtures/valid_workspace/features/product/product_data/pubspec.yaml b/scripts/test/fixtures/valid_workspace/features/product/product_data/pubspec.yaml index 5015b4f..d0a85ad 100644 --- a/scripts/test/fixtures/valid_workspace/features/product/product_data/pubspec.yaml +++ b/scripts/test/fixtures/valid_workspace/features/product/product_data/pubspec.yaml @@ -1,6 +1,6 @@ name: product_data +resolution: workspace environment: sdk: ^3.11.0 dependencies: - product_domain: - path: ../product_domain + product_domain: any diff --git a/scripts/test/fixtures/valid_workspace/features/product/product_domain/pubspec.yaml b/scripts/test/fixtures/valid_workspace/features/product/product_domain/pubspec.yaml index 40349eb..cfc6568 100644 --- a/scripts/test/fixtures/valid_workspace/features/product/product_domain/pubspec.yaml +++ b/scripts/test/fixtures/valid_workspace/features/product/product_domain/pubspec.yaml @@ -1,3 +1,4 @@ name: product_domain +resolution: workspace environment: sdk: ^3.11.0 diff --git a/scripts/test/fixtures/valid_workspace/features/product/product_presentation/pubspec.yaml b/scripts/test/fixtures/valid_workspace/features/product/product_presentation/pubspec.yaml index d147e83..5d07fe0 100644 --- a/scripts/test/fixtures/valid_workspace/features/product/product_presentation/pubspec.yaml +++ b/scripts/test/fixtures/valid_workspace/features/product/product_presentation/pubspec.yaml @@ -1,8 +1,7 @@ name: product_presentation +resolution: workspace environment: sdk: ^3.11.0 dependencies: - product_domain: - path: ../product_domain - ui_kit: - path: ../../../shared/ui_kit + product_domain: any + ui_kit: any diff --git a/scripts/test/fixtures/valid_workspace/pubspec.yaml b/scripts/test/fixtures/valid_workspace/pubspec.yaml index a365626..ca148e2 100644 --- a/scripts/test/fixtures/valid_workspace/pubspec.yaml +++ b/scripts/test/fixtures/valid_workspace/pubspec.yaml @@ -1,3 +1,16 @@ name: _valid_workspace environment: sdk: ^3.11.0 +workspace: + - apps/mobile_app + - features/auth/auth_data_firebase + - features/auth/auth_domain + - features/cart/cart_data + - features/cart/cart_domain + - features/cart/cart_presentation + - features/ideas/ideas_presentation + - features/product/product_data + - features/product/product_domain + - features/product/product_presentation + - shared/api_client + - shared/ui_kit diff --git a/scripts/test/fixtures/valid_workspace/shared/api_client/pubspec.yaml b/scripts/test/fixtures/valid_workspace/shared/api_client/pubspec.yaml index da3b714..3c6c80b 100644 --- a/scripts/test/fixtures/valid_workspace/shared/api_client/pubspec.yaml +++ b/scripts/test/fixtures/valid_workspace/shared/api_client/pubspec.yaml @@ -1,3 +1,4 @@ name: api_client +resolution: workspace environment: sdk: ^3.11.0 diff --git a/scripts/test/fixtures/valid_workspace/shared/ui_kit/pubspec.yaml b/scripts/test/fixtures/valid_workspace/shared/ui_kit/pubspec.yaml index 27b1f48..314752e 100644 --- a/scripts/test/fixtures/valid_workspace/shared/ui_kit/pubspec.yaml +++ b/scripts/test/fixtures/valid_workspace/shared/ui_kit/pubspec.yaml @@ -1,3 +1,4 @@ name: ui_kit +resolution: workspace environment: sdk: ^3.11.0 diff --git a/scripts/test/validate_layers_test.dart b/scripts/test/validate_layers_test.dart index 1329281..01aa976 100644 --- a/scripts/test/validate_layers_test.dart +++ b/scripts/test/validate_layers_test.dart @@ -148,6 +148,13 @@ void main() { expect(err, contains('nothing may depend on an app')); }); + test('resolves dependencies by workspace name and by path', () { + // The fixture is a Dart workspace: alpha_domain declares `alpha_data: + // any` and resolves by name, theta_data keeps a `path:` dependency. + expect(err, contains('alpha_domain depends on alpha_data')); + expect(err, contains('theta_data depends on delta_data')); + }); + test('every violation includes a fix line', () { // One arrow per violation; there are at least the seven planted rules. final arrows = '→'.allMatches(err).length; diff --git a/scripts/validate_layers.dart b/scripts/validate_layers.dart index 089558d..0235e7c 100644 --- a/scripts/validate_layers.dart +++ b/scripts/validate_layers.dart @@ -1,7 +1,7 @@ // FFCA layer-dependency validator. // // Enforces the Feature-First Clean Architecture dependency rules on a Dart -// monorepo's pubspec path dependencies. Used two ways: +// monorepo's pubspec dependencies between its own packages. Used two ways: // - by the plugin hook, incrementally, on every pubspec.yaml edit: // dart run validate_layers.dart --file // - by CI and the ffca-audit skill, across the whole workspace: @@ -23,9 +23,10 @@ import 'dart:io'; // Change cross-feature dependency scope here, not throughout the script. // // Each key is a source layer; the value is the set of target layers it may -// have a path dependency on. External pub dependencies are never path -// dependencies, so they are ignored. Targets not in the allowed set are -// violations. Consequences worth noting: +// depend on. A dependency counts when it resolves to a package in the +// workspace, either through a `path:` or by name, as in a Dart workspace +// (`resolution: workspace`). Anything else is external and ignored. Targets +// not in the allowed set are violations. Consequences worth noting: // - `app` is never an allowed target -> nothing may depend on an app. // - `data`/`presentation` never appear for `domain`/`data` -> domain and // data layers never depend on a presentation or (cross-feature) data layer. @@ -303,6 +304,7 @@ String? _findWorkspaceRoot(String startDir) { Workspace _discover(String root) { final packages = []; + final depsByPackage = >{}; for (final top in ['features', 'apps', 'shared']) { final topDir = Directory('$root/$top'); if (!topDir.existsSync()) continue; @@ -310,16 +312,39 @@ Workspace _discover(String root) { if (entity is! File) continue; if (_basename(entity.path) != 'pubspec.yaml') continue; final pkgDir = _normalize(entity.parent.absolute.path); - packages.add(_buildPackage(pkgDir, entity, top, root)); + final parsed = _parsePubspec(entity); + final package = _buildPackage(pkgDir, entity, parsed.name, top, root); + packages.add(package); + depsByPackage[package] = parsed.deps; } } + + // Resolve dependencies once every package is known. A `path:` wins; a + // dependency without one resolves by name, the way a Dart workspace does. + final byName = {for (final p in packages) p.name: p}; + for (final p in packages) { + for (final dep in depsByPackage[p]!) { + final path = dep.path; + if (path != null) { + p.deps.add(_normalize(_join(p.dir, path))); + } else if (byName[dep.name] case final target?) { + p.deps.add(target.dir); + } + } + } + final byDir = {for (final p in packages) p.dir: p}; return Workspace(packages: packages, byDir: byDir); } -Package _buildPackage(String pkgDir, File pubspec, String top, String root) { - final parsed = _parsePubspec(pubspec); - final name = parsed.name ?? _basename(pkgDir); +Package _buildPackage( + String pkgDir, + File pubspec, + String? parsedName, + String top, + String root, +) { + final name = parsedName ?? _basename(pkgDir); String feature = ''; String layer; @@ -336,11 +361,6 @@ Package _buildPackage(String pkgDir, File pubspec, String top, String root) { layer = _classifyFeatureLayer(name, feature); } - final deps = []; - for (final raw in parsed.pathDeps) { - deps.add(_normalize(_join(pkgDir, raw))); - } - return Package( dir: pkgDir, relPath: pubspec.absolute.path.substring(root.length + 1), @@ -348,7 +368,7 @@ Package _buildPackage(String pkgDir, File pubspec, String top, String root) { top: top, feature: feature, layer: layer, - deps: deps, + deps: [], ); } @@ -361,12 +381,12 @@ String _classifyFeatureLayer(String name, String feature) { } // --------------------------------------------------------------------------- -// Minimal pubspec reader (name + path dependencies only) +// Minimal pubspec reader (name + dependency names and paths only) // --------------------------------------------------------------------------- _ParsedPubspec _parsePubspec(File file) { String? name; - final pathDeps = []; + final deps = <_Dependency>[]; final lines = file.readAsLinesSync(); String? currentTop; @@ -389,21 +409,30 @@ _ParsedPubspec _parsePubspec(File file) { final inDeps = currentTop == 'dependencies' || currentTop == 'dev_dependencies' || currentTop == 'dependency_overrides'; - if (inDeps && indent == 2 && content.endsWith(':')) { - // A dependency entry; scan its nested block for a `path:` value. - for (var j = i + 1; j < lines.length; j++) { - final l2 = _stripComment(lines[j]); - if (l2.trim().isEmpty) continue; - final ind2 = l2.length - l2.trimLeft().length; - if (ind2 <= 2) break; - final c2 = l2.trim(); - if (c2.startsWith('path:')) { - pathDeps.add(_unquote(c2.substring('path:'.length).trim())); + if (inDeps && indent == 2 && content.contains(':')) { + // A dependency entry: `foo: ^1.0.0`, a bare `foo:`, or `foo:` with a + // nested block. Scan the nested block for a `path:` value. + final depName = _unquote(content.split(':').first.trim()); + String? path; + if (content.endsWith(':')) { + for (var j = i + 1; j < lines.length; j++) { + final l2 = _stripComment(lines[j]); + if (l2.trim().isEmpty) continue; + final ind2 = l2.length - l2.trimLeft().length; + if (ind2 <= 2) break; + final c2 = l2.trim(); + if (c2.startsWith('path:')) { + path = _unquote(c2.substring('path:'.length).trim()); + } } } + // An override without a path only pins a version; it adds no edge. + if (path != null || currentTop != 'dependency_overrides') { + deps.add(_Dependency(name: depName, path: path)); + } } } - return _ParsedPubspec(name: name, pathDeps: pathDeps); + return _ParsedPubspec(name: name, deps: deps); } String _stripComment(String line) { @@ -504,13 +533,19 @@ class Package { final String top; // features | apps | shared final String feature; // feature folder name (features/ only) final String layer; // domain | data | presentation | shared | app | unknown - final List deps; // normalized absolute dirs of path dependencies + final List deps; // normalized absolute dirs of workspace dependencies } class _ParsedPubspec { - _ParsedPubspec({required this.name, required this.pathDeps}); + _ParsedPubspec({required this.name, required this.deps}); final String? name; - final List pathDeps; + final List<_Dependency> deps; +} + +class _Dependency { + _Dependency({required this.name, this.path}); + final String name; + final String? path; // relative `path:` value, null when resolved by name } class _Violation { diff --git a/skills/ffca-architecture/SKILL.md b/skills/ffca-architecture/SKILL.md index 3e7995c..f3b4088 100644 --- a/skills/ffca-architecture/SKILL.md +++ b/skills/ffca-architecture/SKILL.md @@ -28,7 +28,7 @@ The classes in `use_cases/` are named **Command** (mutates, `execute`) and **Que ## Before you wire dependencies -Read `references/ffca/project_structure.md`, section *Dependency rules*, before adding any path dependency to a pubspec. That section's table is the whole policy, and it is what `scripts/validate_layers.dart` implements. The hook enforces it on every pubspec edit and blocks the edit on a violation, so confirm the direction first: +Read `references/ffca/project_structure.md`, section *Dependency rules*, before adding a dependency on another workspace package to a pubspec. That section's table is the whole policy, and it is what `scripts/validate_layers.dart` implements. The hook enforces it on every pubspec edit and blocks the edit on a violation, so confirm the direction first: - apps depend on features and shared - shared depends only on external packages diff --git a/skills/ffca-feature/SKILL.md b/skills/ffca-feature/SKILL.md index 16c795d..59d1496 100644 --- a/skills/ffca-feature/SKILL.md +++ b/skills/ffca-feature/SKILL.md @@ -58,7 +58,7 @@ When a screen needs to display something owned by another feature, the module ca ## Pubspecs and dependencies -When you add path dependencies, follow `references/ffca/project_structure.md`, section *Dependency rules*. The pubspec hook blocks edits that violate a layer rule and tells you the fix, so set the direction correctly the first time: data depends on its own domain, presentation depends on domains and never on a data layer, neither depends on an app. +When you add dependencies between packages, follow `references/ffca/project_structure.md`, section *Dependency rules*. The pubspec hook blocks edits that violate a layer rule and tells you the fix, so set the direction correctly the first time: data depends on its own domain, presentation depends on domains and never on a data layer, neither depends on an app. If the app defers this feature's import, read `references/ffca/project_structure.md`, section *Deferred loading*, before adding a dependency on another feature's presentation package. An eager edge from a deferred package silently cancels the code splitting downstream.