Flutter architecture
Enforces a right-sized feature-first layered MVVM Flutter architecture — features are folders and cross-cutting foundations become packages only when a compile wall earns it, a strict downward-only dependency DAG, dumb Views over one Notifier/AsyncNotifier ViewModel per feature, repositories as the single source of truth and single write path returning immutable domain values, abstractions only where something genuinely can't run in a test, and Riverpod 3.x as the one context-free DI+state mechanism (no get_it/injectable/package:provider container). Use when creating a Flutter feature or file, deciding folder-vs-package or where a class belongs, naming a Screen/Notifier/Repository/Service, wiring providers or a composition-root/bootstrap.dart, adding a use-case/domain layer, resisting over-engineering on a small app, or reviewing whether a change respects the layer boundaries.From its SKILL.md
npx -y skills add zakariaf/Flutter-Skills --skill flutter-architectureAssembled from the repository path, not quoted from the project. Check it against their README if it does not work.
2 things to look at
- 23 days oldThe repository was created 23 days ago. New is not bad, but a brand new repository carrying a familiar-sounding name is the shape a typosquat arrives in, and there has been no time for anyone else to find a problem with it.
- 0 stars0 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.
SKILL.md
18.1 KB, ~4.1k tokens by cl100k_base, as published. Nobody here has run it
Flutter Architecture
Structure every Flutter app as a layered, feature-first MVVM system, sized to the app. You must always answer "where does this code belong?" in one second. Based on Flutter's official Guide to app architecture, with Riverpod 3.x as the one mechanism for both state and dependency injection.
Two failure modes kill an architecture: under-structure (a widget touching a database) and
over-structure (a *UseCase wrapping one repository call, an interface over code that already runs in
a test). This skill fights both. Start small; add a layer only when it carries a load you can name.
Read the reference for the task at hand:
references/module-and-layers.md— the layer model, folder-vs-package continuum, the DAG, barrels, feature-folder anatomy, and the "when multi-package (workspace)" note.references/right-sizing.md— the reject-over-engineering table, "abstract only what you can't test", and how architecture scales with app size.references/state-di-riverpod.md— provider graph, keepAlive vs autoDispose, placeholder-override composition root, isolate-safe factories,ProviderContainertests.
Run scripts/check_architecture.sh before opening a PR.
Non-negotiable rules
- Answer "where does this belong?" in one second. Group by feature first, layer second. No
giant app-wide
screens/,models/, orwidgets/bucket — those smear one feature across the tree and force a name lookup on every edit. - Two layers minimum: UI and Data. A domain/use-case layer only when logic spans multiple
repositories (projection, aggregation, a multi-step workflow). A
*UseCasethat forwards one repository call is a rename, not a boundary — Flutter's own guidance rates the domain layer conditional and says most apps don't need it. - Abstract exactly what cannot run in a test — name what the abstraction makes testable. A
platform channel, network client, or plugin that can't execute in
flutter testearns an interface + a fake. Code that already runs headless (an in-memory DB, a pure calculator, a repository over it) stays concrete. "It's cleaner" is not a load; if you can't name the seam it buys, don't add it. - Data flows one way; never skip or reverse a hop. Data down:
Service → Repository → ViewModel → View. Events up:View → ViewModel → Repository. A widget calls its ViewModel only; a ViewModel calls repositories only. No two-way binding, no widget reaching a data source. - The View is dumb. A View does layout,
if/switchon state, animation, and navigation — nothing else. No business logic, no formatting/number math, notry/catch, no data access. If a widget computes or fetches, it's in the wrong layer. - One ViewModel per feature, over immutable state. The ViewModel holds private mutable state
and exposes intent methods (
load(),add(...)); every transition assigns a new immutable value with value equality. No public setters, no mutable field the UI edits in place. - Repositories are the single source of truth AND the single write path. Every mutation is one named repository method that persists first, then publishes (one transaction where the store supports it). Never persist-after-publish — a crash in between credits state that never saved.
- Derive, don't store. Counts, totals, streaks, filtered lists are computed from the source of truth (a stream/derived provider), never a second stored counter that can drift out of sync.
- Map at the boundary; keep domain types out of the edges. Repositories map storage rows / JSON
DTOs → immutable domain value objects. A generated Drift row or a
Mapnever reaches a ViewModel or widget; unit math and locale formatting never happen in a widget. - Inject through providers; depend on the abstraction where it earns one. No globals, no
singletons, no service locator reached from a widget, no
DateTime.now()outside an injectedClock. A composition root wires the object graph once. - Features are FOLDERS; foundations become PACKAGES only when a compile wall is load-bearing. Default to a single package. A feature never imports another feature — share via a foundation layer or navigate by route ID. The dependency graph is a strict downward-only DAG.
- Errors are typed values at boundaries. Repositories/use-cases return a sealed
Result/Failurerather than throwing across a layer; the UI switches exhaustively. Seeerror-handling-typed-results.
The layered shape (single package, the default)
project-structure-and-packages OWNS the physical tree; this is the feature-first layout it defines,
shown here for the layer semantics. Features are FOLDERS under lib/features/; a feature's Views and
its one ViewModel live in presentation/.
lib/
main.dart # thin entrypoint → bootstrap()
bootstrap.dart # composition root: build infra, override placeholder providers, runApp
app.dart # MaterialApp.router, theme, top-level ProviderScope children
core/ # PURE foundation, NO Flutter: value objects, Result/Failure, the Clock seam,
# extensions, pure calculators (the sanctioned foundation, not a junk-drawer)
data/ # shared data layer: Drift db, DAOs, repositories, row→model mappers
services/ # injectable side-effect ports + live impls (services/native/ = MethodChannel)
routing/ # the single go_router config, typed routes, guards
theme/ l10n/ # ThemeExtension token sets; generated AppLocalizations + ARB
features/
tasks/
presentation/
task_list_screen.dart # View: dumb ConsumerWidget
task_list_notifier.dart # ViewModel: StreamNotifier over immutable state
widgets/ # widget CLASSES used only by this feature
application/ # optional: use-cases, ONLY when logic spans repositories
domain/ # optional: feature-local models
# data/ usually ABSENT — features read shared repos from lib/data/
Rules of thumb: core/ depends on nothing above it and stays free of BuildContext; data/ has no
Flutter UI imports; a feature depends on core/data/services, never on a sibling feature. Add a
feature-local domain/ + application/ (use-cases) folder only when logic spans repositories.
The feature slice (Riverpod-first "how")
A dumb View reads one ViewModel; the ViewModel holds private state and exposes intent methods.
// features/tasks/presentation/task_list_notifier.dart — the ViewModel.
// build() returns the live repository stream; a mutation commits through the single write
// path and the watched stream RE-EMITS. No manual `state = ...` republish.
final class TaskListNotifier extends StreamNotifier<List<Task>> {
@override
Stream<List<Task>> build() {
ref.onDispose(() {/* controllers/subscriptions released here */});
return ref.watch(taskRepositoryProvider).watchTasks(); // track every emission
}
Future<void> add(String title) async { // intent method — no setter
final id = ref.read(idGeneratorProvider)(); // injected id source — never a UI UniqueKey
await ref.read(taskRepositoryProvider).add(Task.create(id: id, title: title));
// No republish: the committed write makes watchTasks() re-emit and build()'s stream updates state.
}
}
final taskListNotifierProvider =
StreamNotifierProvider<TaskListNotifier, List<Task>>(TaskListNotifier.new);
The repository behind it is the single source of truth AND single write path — a watchTasks()
stream plus an add() that persists first, then the stream re-emits. Give it an abstract interface
only when the real impl can't run in a test (rule 3); over an in-memory DB a concrete class is fine.
// features/tasks/task_list_screen.dart — the View is dumb: read one provider, render.
final class TaskListScreen extends ConsumerWidget {
const TaskListScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final tasks = ref.watch(taskListNotifierProvider);
return tasks.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => ErrorView(onRetry: () => ref.invalidate(taskListNotifierProvider)),
data: (list) => ListView(
children: [for (final t in list) TaskTile(task: t)], // widget CLASS, not a _buildTile()
),
);
}
}
Providers ARE the dependency injection. Wire deps via ref.watch, key per-entity state with family,
and use autoDispose for per-screen controllers. See state-management-riverpod for the full API
discipline (watch/read/listen split, .select, stale-closure hazards).
The composition root (single write of the object graph)
Infra that constructs asynchronously (a database, dirs, a key store) is injected via placeholder
providers that throw until overridden in bootstrap.dart — the same seam tests use.
// data/providers.dart — placeholder: throws until bootstrap() overrides it (and every test overrides it).
final appDatabaseProvider =
Provider<AppDatabase>((ref) => throw UnimplementedError('override in bootstrap()'));
// bootstrap.dart — the ONE place async infra is built and the graph is composed.
Future<void> bootstrap() async {
WidgetsFlutterBinding.ensureInitialized();
final db = await openAppDatabase(); // plain top-level factory, no BuildContext
runApp(ProviderScope(
overrides: [appDatabaseProvider.overrideWithValue(db)],
child: const App(),
));
}
Keep infra (DB, repositories, clock) keepAlive; never autoDispose a singleton that can be torn
down mid-operation. See app-startup-and-bootstrap for main() ordering and error handlers.
Right-sizing: folder vs package, and what to abstract
Scale the architecture to the app. The three questions, in order:
- Does this need a new folder or a new file? Almost always a file in an existing feature/layer.
- Does this need a domain/use-case layer? Only if logic spans multiple repositories. One-repo CRUD does not.
- Does this need an interface? Only if the real implementation can't run in
flutter test, or you genuinely swap dev/prod impls. Name the fake it enables. - Does this need a separate package? Only when a compile-time wall is load-bearing — divergence
would be a correctness bug (canonical units/money math, the whole data layer, a scheduler). See the
reject-over-engineering table in
references/right-sizing.md.
When multi-package (workspace). A large app (many teams, a pure-Dart core you golden-test, a reusable design system) may split foundations into packages under a Dart pub workspace. Then: each member sets
resolution: workspacewith one rootpubspec.lock; each package exposes one barrel (core.dart) over a privatesrc/— never import another package'ssrc/; the pure-Dartcorepackage declares noflutterdependency so a stray import is a compile error; the DAG stays acyclic (core→ nothing internal; everything →core; the app shell → all). This is an option a big app grows into, never a requirement for a small one — a single package with folders is the default.
Anti-patterns
- A
*UseCase/ domain layer wrapping one repository call. Pure ceremony; a rename of the repository method. Add the layer only when logic spans repositories. - An
abstract interface classwhose only content is the word "Repository", over code that runs in a test. The seam buys nothing; a Map-backed fake can even accept rows the real schema rejects. Test the concrete class against a real in-memory store. - Business logic, formatting, or number/unit math in a View. Move it to the ViewModel or
core. http/sqflite/SharedPreferences/a plugin called insidebuild()orinitState(). Data access belongs behind a repository, reached through a provider.- Persist-after-publish / optimistic write before the commit returns. A crash in the gap credits state that never saved. Persist first, then the stream republishes.
- A stored counter mirroring derivable data (a
countcolumn next to the rows). It drifts. Derive it from the source of truth. - A ViewModel importing
package:flutter/material.dartforBuildContext/widgets. ViewModels are UI-framework-light; passingBuildContextinto the data layer is worse. get_it/injectable/package:provideras a second DI container alongside Riverpod, or a global singleton /staticmutable store reached from a widget. Providers are the one DI mechanism.- A cross-feature import creating a cycle. Share via a foundation layer or navigate by route ID.
- Splitting a small app into packages "to be clean". Multiplies pubspecs and codegen for zero compile-time guarantee. Promote to a package only for a load-bearing wall.
Definition of done
- New code lives under the right
features/<x>/orcore/datalocation; grouping is feature-first. - The View is a dumb
ConsumerWidget— no data access, no business logic, no formatting, notry/catch. - The ViewModel is a
Notifier/AsyncNotifier/StreamNotifierexposing immutable state + intent methods; no public setters, nonotifyListeners()in the Riverpod path. - Every mutation routes through one repository method that persists before publishing; the watched stream re-emits (no manual republish); derived reads are streams/derived providers, not stored counters.
- Repositories map storage rows/DTOs → immutable value objects; no Drift/DTO/
Maptype reaches a ViewModel or widget. - Interfaces exist only where the real impl can't run in a test (or a real dev/prod swap); each earns a named fake.
- Dependencies are injected via providers from a composition root; no new globals/singletons; no
DateTime.now()outside an injected clock. - A domain/use-case layer was added only if logic spans repositories; the app is a single package unless a compile wall is load-bearing.
-
scripts/check_architecture.shpasses;dart analyzeis clean.
Related skills
- See
state-management-riverpodfor the Notifier/AsyncNotifier API, watch/read/listen split,family+autoDispose, and stale-closure hazards. - See
widget-compositionfor the deep dive on building the dumb View (rule 5): const widget classes over_buildX()helpers, leanbuild(), and where the widget sits in this layer DAG. - See
scaffold-feature-modulefor the step-by-step feature folder + typed go_router registration workflow. - See
project-structure-and-packagesfor pubspec-as-audit-artifact, barrels, and one-way layering mechanics. - See
app-startup-and-bootstrapfor main() ordering, global error handlers, and DI overrides at the root. - See
error-handling-typed-resultsfor the sealedResult/Failurespine returned across boundaries. - See
dart3-idioms-and-coding-standardsfor sealed types, exhaustive switches, and immutable value types. - See
naming-conventionsfor theScreen/Notifier/Repository/Service/Failurerole suffixes a grep can read. - See
persistence-driftfor the DAO/repository seam and transaction-per-mutation persistence.
References
- Flutter — Guide to app architecture
- Flutter — Architecture recommendations
- Flutter — UI layer case study (what a dumb View may contain)
- Flutter — Offline-first support (local store authoritative)
- Riverpod 3 documentation
- Dart — Pub workspaces (monorepo support)
Provider / ChangeNotifier appendix
The same rules map onto the official Flutter-guide stack (package:provider + ChangeNotifier) when
a codebase uses it instead of Riverpod. Rules 1–9, 11, 12 are unchanged. Only the "how" of state + DI
differs:
- ViewModel =
ChangeNotifier. It still holds private mutable fields and exposes intent methods; after a real state change it callsnotifyListeners()once — never insidebuild()and never in a tight loop. Expose state via getters that return unmodifiable views (List.unmodifiable). Immutability still applies to the domain models the ViewModel holds (copyWith, value equality). - DI = constructor injection wired through
providernear the root. AMultiProviderat the app root creates services and repositories; a screen'sChangeNotifierProviderbuilds its ViewModel fromcontext.read<XRepository>(). Depend on the abstract repository type so tests inject a fake. - Read vs listen:
context.watch<T>()/Consumerto rebuild on change;context.read<T>()in callbacks. Never passBuildContextdown into the data layer to read providers — pass the data. - The bans still hold: no
get_it/service locator reached from a widget, no global mutable state, no data access in a View. Everything else in this skill (layering, single write path, derive-don't-store, map-at-the-boundary, abstract-only-what-you-can't-test, folder-vs-package) is identical.
What ships with it: 6 files
34.8 KB alongside SKILL.md, 1 of them executable
examples/
- composition_root.dart3.9 KB
- feature_slice.dart6.2 KB
references/
- module-and-layers.md7.0 KB
- right-sizing.md5.5 KB
- state-di-riverpod.md8.8 KB
scripts/
- check_architecture.shruns3.4 KB