Scaffold Feature Module
ASecurityStands up one navigable feature the same way every time — a fixed lib/features/<feature>/presentation/ folder (dumb View + one 1:1 Notifier/AsyncNotifier ViewModel + widgets/ + scoped <feature>_providers.dart), downward-only dependencies with no cross-feature imports, reactive reads as scoped StreamProviders over a repository .watch(), every mutation through one repository single-write-path method, typed Result/Failure surfaced as AsyncValue, a typed go_router route carrying identity in path ...
- 15 stars
- 0 votes
- 0 copies
- 3 views
- Added September 19, 2026
Security analysis
100/100Pro scans all 8 files and shows the line behind each finding
npx -y skills add zakariaf/Flutter-Skills --skill scaffold-feature-module --agent claude-codeAre you the author of Scaffold Feature Module?
Add the live security badge to your README. It updates with every re-scan.
[](https://www.skillsdirectory.com/skills/zakariaf-scaffold-feature-module)---
name: scaffold-feature-module
description: Stands up one navigable feature the same way every time — a fixed lib/features/<feature>/presentation/ folder (dumb View + one 1:1 Notifier/AsyncNotifier ViewModel + widgets/ + scoped <feature>_providers.dart), downward-only dependencies with no cross-feature imports, reactive reads as scoped StreamProviders over a repository .watch(), every mutation through one repository single-write-path method, typed Result/Failure surfaced as AsyncValue, a typed go_router route carrying identity in path params, ARB parity across locales, family+autoDispose keying, and EdgeInsetsDirectional-only geometry. Use when adding a screen, tab, or feature folder; wiring a feature Notifier/ViewModel or a scoped stream provider; registering a route; adding ARB keys for a new screen; splitting an over-grown feature; or adding a persisted record its View reads.
---
# Scaffold a feature module
Stand up one feature the **same way every time**: a folder `lib/features/<feature>/` whose
`presentation/` holds a dumb View, its 1:1 Notifier/AsyncNotifier ViewModel, leaf `widgets/`, and
scoped providers (feature-local models live in an optional `domain/`). A feature is a folder, not a
package. It reads shared repositories from `lib/data/` and shared foundation from `lib/core/`, routes
every write through them, and depends only *downward* — never sideways into another feature.
(The single-package tree is owned by `project-structure-and-packages`.)
Agnostic core (holds under any state library): the View is dumb; one ViewModel per feature owns
private mutable state and exposes intent methods that assign a new immutable value; reads derive from
the repository (the single source of truth), writes go through one repository method (the single write
path, persist-before-publish); the feature depends on abstractions and navigates by route ID. The
Riverpod-first "how" below wires that with Notifiers, scoped StreamProviders, and providers-as-DI.
Read the reference for the task at hand:
- `references/anatomy-and-wiring.md` — the fixed folder shape, the Notifier + scoped stream, watch/read/listen, family+autoDispose vs keepAlive, the single write path, and the ViewModel test.
- `references/routing-and-l10n.md` — typed go_router registration (path params, not `state.extra`) and the ARB parity workflow.
- `references/add-persisted-record.md` — the optional model → table → DAO → repository chain when the feature reads a brand-new record (defers to `persistence-drift`).
Run `scripts/scaffold_feature.sh <feature>` to generate the skeleton, then `scripts/verify_feature.sh` before a PR.
## Non-negotiable rules
1. **A feature is a FOLDER, never a new package.** `lib/features/<feature>/`, `lower_snake_case`, named
for the screen (`tasks`, `task_detail`, `settings`, `onboarding`). A new folder is a normal
addition; a new package is a deliberate boundary decision (see `project-structure-and-packages`).
Do not invent numbered-folder or fixed-package-count conventions. The app package does **not** use
`lib/src/` — that is a multi-package convention (see `project-structure-and-packages`).
2. **Fixed anatomy, one primary public type per file.** Under `lib/features/<feature>/presentation/`:
`<feature>_screen.dart` (the dumb View), `<feature>_notifier.dart` (the 1:1 Notifier/AsyncNotifier
ViewModel — file name = its primary declaration, per `naming-conventions`), `widgets/` (leaf views),
and `<feature>_providers.dart` (providers scoped to this feature, never global). A feature-local
model lives in an optional `domain/<feature>.dart`. Predictable shape = a reviewer finds any piece
in one second.
3. **The View is dumb; the ViewModel is 1:1.** The View reads exactly one controller and renders —
only show/hide `if`s, layout, animation, and navigation. No repository call, no `try/catch`
business logic, no unit math or formatting in `build()`. Logic lives in the ViewModel.
4. **A feature NEVER imports another feature.** Share via a foundation layer (core/data), or navigate
by route **ID**. Dependencies point *down* only — a lint/grep gate fails a cross-feature import.
Sideways coupling turns two features into one un-deletable knot.
5. **Read the store only through repository providers; wrap `.watch()` in a scoped StreamProvider.**
Never touch Drift, secure storage, or a platform channel from a widget or ViewModel. Scope the
stream by its key (an owner/parent id, a time window) so one write never re-emits app-wide.
6. **`ref.watch` in `build`, `ref.read` in callbacks, `ref.listen` for one-shot effects** (SnackBar,
navigate). Watching in a callback re-subscribes; reading in `build` misses rebuilds. (See
`state-management-riverpod` for the full split.)
7. **Per-key state is `family` + `autoDispose`; singletons are `keepAlive`.** Any provider scoped to a
specific entity/session is `family`-keyed on a stable equatable value and `autoDispose`d so it
dies with its screen. Never an un-keyed "current thing" provider; never `autoDispose` the
app-scope db/service singletons.
8. **Every mutation is one repository method — the single write path, persist-before-publish.** A
command on the ViewModel calls a named repository method that commits, *then* the stream re-emits
and the UI rebuilds. No optimistic republish, no `setState`, no manual cache poke, no "save later".
9. **Derive, don't store.** Counts, totals, filtered lists, streaks are computed from the source of
truth (a derived/stream provider), never a second stored counter that drifts out of sync.
10. **Repositories map rows → value objects; the ViewModel passes value objects.** A Drift row,
companion, or `Map` never reaches a ViewModel or widget. Measured quantities are value objects
(see `value-objects-money-and-units`) — never a bare `double` for money.
11. **Errors are typed values surfaced as `AsyncValue`.** Repositories return a sealed `Result`/
`Failure`; the ViewModel maps it to `AsyncData`/`AsyncError`; the View `switch`es exhaustively and
**localizes the Failure at the presentation edge** — never a raw exception string. (See
`error-handling-typed-results`.)
12. **Register in the ONE go_router with a typed route; identity rides in path params.** Add a
`TypedGoRoute`/`GoRouteData`; carry deep-linkable identity in path params (`:taskId`), never
`state.extra` (null on cold start/OS restore). Full-screen add/edit flows set
`parentNavigatorKey: rootNavigatorKey`. The router itself — shell/branch structure, redirect/auth
guards, deep links, transitions — is owned by `navigation-and-routing`; this feature only
registers its route into it.
13. **Navigate from the View or a router redirect — never from the ViewModel.** The controller
publishes state; the View or a `redirect` decides what is on screen. A controller that pushes a
route couples logic to navigation and breaks headless tests.
14. **Inject "now"; never call `DateTime.now()` in a feature.** The ViewModel reads a `Clock`
provider so a whole flow is assertable with a pinned fake clock. (See `async-safety`.)
15. **Directional geometry and localized strings only.** `EdgeInsetsDirectional`/`AlignmentDirectional`
— never `.left/.right`, `Alignment.centerLeft`, or `TextAlign.left`. Every user-facing string comes
from gen-l10n across all locale ARBs with key + placeholder parity. (See `i18n-rtl-l10n`.)
16. **Test the ViewModel by faking the repository, not the Notifier.** `ProviderContainer.test()` +
`overrideWith` a fake repository and `clockProvider.overrideWithValue(Clock.fixed(t))`; no widget
pump; verify wiring and UI-state mapping only. (See `testing-strategy`.)
## The canonical Notifier + scoped stream
Default to **hand-written providers** (`state-management-riverpod` owns the discipline; `@riverpod`
codegen is an optional alternative, not the default). Colocate the scoped stream and the command
Notifier in `<feature>_providers.dart` / `<feature>_notifier.dart`. Full file:
`examples/tasks_notifier.dart`.
```dart
// features/tasks/presentation/tasks_providers.dart — reactive backbone, scoped so one write is not app-wide.
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:app/core/result.dart'; // Result, Ok, Err, Failure (shared foundation, lib/core/)
import 'package:app/data/task_repository.dart'; // taskRepositoryProvider (keepAlive; lives in lib/data/)
import '../domain/task.dart'; // feature-local immutable model (value objects, not rows)
/// Wrap the repository's scoped `.watch()` in a family StreamProvider, keyed by projectId.
/// `.autoDispose` frees the subscription when the screen unmounts. The repository maps rows ->
/// domain models at the boundary; this never sees a Drift row.
final taskListProvider =
StreamProvider.autoDispose.family<List<Task>, String>((ref, projectId) =>
ref.watch(taskRepositoryProvider).watchTasks(projectId));
```
```dart
// features/tasks/presentation/tasks_notifier.dart — the 1:1 ViewModel: command state + writes.
final tasksNotifierProvider =
AsyncNotifierProvider.autoDispose<TasksNotifier, void>(TasksNotifier.new);
class TasksNotifier extends AsyncNotifier<void> {
@override
FutureOr<void> build() {} // no synchronous heavy work here
/// A command the View binds. The write goes through ONE repository method (single write path);
/// the typed Result is mapped to AsyncValue for the View to switch on exhaustively.
Future<void> add(Task draft) async {
state = const AsyncLoading();
final Result<void, Failure> result =
await ref.read(taskRepositoryProvider).addTask(draft);
state = switch (result) {
Ok() => const AsyncData(null),
Err(:final failure) => AsyncError(failure, StackTrace.current),
};
// No manual republish: the committed write makes the Drift stream re-emit and the list rebuilds.
}
}
```
## The dumb View
The View watches the scoped stream, renders inside `.when`, and calls `ref.read(...notifier)` from
callbacks. It `ref.listen`s the controller for one-shot effects. Full file: `examples/tasks_screen.dart`.
```dart
class TasksScreen extends ConsumerWidget {
const TasksScreen({required this.projectId, super.key});
final String projectId;
@override
Widget build(BuildContext context, WidgetRef ref) {
final l10n = AppLocalizations.of(context);
ref.listen(tasksNotifierProvider, (prev, next) { // one-shot effects only
if (next case AsyncError(:final error)) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(localizeFailure(l10n, error))), // typed Failure -> localized text
);
}
});
final async = ref.watch(taskListProvider(projectId)); // watch in build
return Scaffold(
appBar: AppBar(title: Text(l10n.tasksTitle)),
body: async.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => FailureView(message: localizeFailure(l10n, e)),
data: (tasks) => tasks.isEmpty
? EmptyState(title: l10n.tasksEmptyTitle)
: ListView.builder(
padding: const EdgeInsetsDirectional.symmetric(horizontal: 16, vertical: 8),
itemCount: tasks.length,
itemBuilder: (context, i) => TaskTile(
task: tasks[i],
onTap: () => TaskDetailRoute( // navigate by ID, from the View
projectId: projectId,
taskId: tasks[i].id,
).go(context),
),
),
),
);
}
}
```
## The two manual steps the generator can't do
1. **Register the route** in the single go_router (`TypedGoRoute`, path-param identity,
`parentNavigatorKey: rootNavigatorKey` for full-screen flows), then regenerate typed routes with
build_runner. See `references/routing-and-l10n.md`.
2. **Add the strings** to the template ARB first, then mirror into every locale ARB with identical
placeholder names and ICU structure. Run the parity check, then codegen, then analyze.
## Optional: the feature reads a brand-new persisted record
If the screen needs a record that does not exist yet, add the chain **before** wiring the feature:
an immutable value type (pure, no store import) → a Drift table with schema invariants → a DAO that
maps rows to the value type → a repository method that opens one transaction. Then the feature reads
its scoped `.watch()` stream as usual. The full ritual lives in `persistence-drift` and `run-migration`;
`references/add-persisted-record.md` is the short bridge.
## Anti-patterns
- **A new package when you only need a screen.** A screen is a folder; a package is a boundary decision.
- **Two primary types in one file, naming the ViewModel `<feature>_view_model.dart`/`*Controller` on the Riverpod path, or naming the View `<feature>_view.dart` when the convention is `_screen.dart`.** One primary public type per file, file name = its declaration (`naming-conventions`); the Riverpod ViewModel is `<feature>_notifier.dart` → `<Feature>Notifier`.
- **Repository/engine calls or `try/catch` business logic in `build()`.** The View reads one controller and renders; logic lives in the ViewModel.
- **Touching Drift/secure-storage/a platform channel from a widget or ViewModel.** Reads go through a repository provider; the row shape is a data-layer secret.
- **Optimistic republish before commit, `setState`, a manual cache poke, or a debounced "save later".** Every write is one repository method that persists, then the stream re-emits.
- **A stored counter trusted as truth.** Derive counts/totals/streaks from the source of truth on read.
- **An un-keyed "current entity" provider, or `autoDispose` on app-scope singletons.** Per-key state is `family` + `autoDispose`; singletons are `keepAlive`.
- **`DateTime.now()` in a feature.** Read an injected `Clock` provider so flows are assertable.
- **`state.extra` for deep-linkable identity, or `Navigator.push` of a primary flow.** Path params + a typed `GoRoute`; the View/redirect decides navigation, not the controller.
- **Importing another feature's folder.** Depend down, never sideways.
- **Hard-coded left/right geometry or an inline UI literal.** `EdgeInsetsDirectional` + strings from gen-l10n.
- **Mocking the `Notifier` in tests instead of faking the repository.** Fake the seam; test the wiring.
## Definition of done
- [ ] Feature is a folder `lib/features/<feature>/` with the fixed anatomy under `presentation/`: `<feature>_screen.dart` (dumb View), `<feature>_notifier.dart` (1:1 Notifier/AsyncNotifier), `widgets/`, scoped `<feature>_providers.dart`.
- [ ] The View reads exactly one controller and renders; no repository call or business `try/catch` in `build()`; `.when` surfaces a calm error.
- [ ] Dependencies point down only; no import of another feature's folder; no Drift/DAO/secure-storage/platform-channel import in the feature.
- [ ] Reads are scoped StreamProviders over a repository `.watch()`; per-key providers are `family`-keyed on a stable value and `autoDispose`d; singletons are `keepAlive`.
- [ ] Every mutation routes through one named repository method (single write path), committing before republishing; derived state is computed on read, never stored.
- [ ] The ViewModel passes value objects; no Drift row/companion/`Map` reaches a widget; money is not a bare `double`.
- [ ] Errors are a sealed `Result`/`Failure` mapped to `AsyncValue`; the View switches exhaustively and localizes the Failure at the edge.
- [ ] "Now" comes from an injected `Clock`; no `DateTime.now()` in the feature.
- [ ] A typed `GoRoute` is registered in the single router; identity in path params; full-screen flows set `parentNavigatorKey`; no navigation from the ViewModel.
- [ ] Strings come from gen-l10n with template-first + parity; geometry is `Directional`-only.
- [ ] ViewModel tested with `ProviderContainer.test()` + a fake repository and a fixed `Clock` (`clockProvider.overrideWithValue(Clock.fixed(t))`, not a mocked Notifier).
- [ ] `dart format` + `dart analyze --fatal-infos` clean; `scripts/verify_feature.sh` passes.
## Related skills
- `flutter-architecture` — the layer model and downward-only DAG this scaffold obeys.
- `state-management-riverpod` — the Notifier/AsyncNotifier discipline, watch/read/listen, family+autoDispose, manual-providers-first.
- `navigation-and-routing` — owns the single go_router this feature registers its typed route into.
- `forms-and-input` — the Form/validation/focus discipline for the add/edit flow this feature launches.
- `widget-composition` — dumb Views, small const widget classes, structural layout.
- `error-handling-typed-results` — the sealed `Result`/`Failure` spine surfaced as `AsyncValue`.
- `persistence-drift` and `run-migration` — the repository/DAO/table/migration ritual behind a new record.
- `i18n-rtl-l10n` — the ARB parity + Directional-geometry contract.
- `value-objects-money-and-units` — the canonical value objects the ViewModel passes.
- `testing-strategy` — faking the repository (and `clockProvider`) to test the ViewModel headlessly.
- `project-structure-and-packages` — owns the single-package tree; when a foundation genuinely earns a package instead of a folder.
## References
- Flutter — Guide to app architecture: https://docs.flutter.dev/app-architecture
- Riverpod 3.x: https://riverpod.dev
- go_router — typed routes: https://pub.dev/documentation/go_router/latest/topics/Type-safe%20routes-topic.html
- Flutter — Internationalization (gen-l10n): https://docs.flutter.dev/ui/accessibility-and-internationalization/internationalization
## Provider / ChangeNotifier appendix
Using the official Flutter-guide stack (`package:provider` + `ChangeNotifier`)? The rules are
identical; only the wiring changes:
- The ViewModel is a `ChangeNotifier`; intent methods mutate private fields and call
`notifyListeners()` once at the end (still one immutable state snapshot's worth of change per call).
- Provide it with `ChangeNotifierProvider` at the feature route; the View reads it with
`context.watch<TasksViewModel>()` in `build` and `context.read<TasksViewModel>()` in callbacks —
the same watch-in-build / read-in-callbacks split as Riverpod.
- Reactive reads: expose a repository `Stream` and fold it with a `StreamProvider` (the `provider`
package's `StreamProvider`, or `StreamBuilder`) — do **not** cache a mutable copy in the ViewModel;
derive on emit.
- Per-screen scoping replaces `family`+`autoDispose`: create the `ChangeNotifierProvider` at the
screen so it disposes with the route; keep app-scope services in a root provider.
- Dispose controllers/subscriptions in `ChangeNotifier.dispose()`.
Everything else — dumb View, single write path, typed `Result`/`Failure`, path-param routing,
injected clock, ARB parity, Directional geometry, faking the repository in tests — is unchanged.
Files in this skill
- SKILL.md
- examples/tasks_notifier.dart
- examples/tasks_screen.dart
- references/add-persisted-record.md
- references/anatomy-and-wiring.md
- references/routing-and-l10n.md
- scripts/scaffold_feature.sh
- scripts/verify_feature.sh
Attribution
Comments
Loading comments…
Newsletter
Get the weekly list of safe new skills, and the ones we pulled.