agentsclimarketplace

Flutter architecture

Skill zakariaf/Flutter-Skills/skills/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

Install
npx -y skills add zakariaf/Flutter-Skills --skill flutter-architecture

Assembled 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, ProviderContainer tests.

Run scripts/check_architecture.sh before opening a PR.

Non-negotiable rules

  1. Answer "where does this belong?" in one second. Group by feature first, layer second. No giant app-wide screens/, models/, or widgets/ bucket — those smear one feature across the tree and force a name lookup on every edit.
  2. Two layers minimum: UI and Data. A domain/use-case layer only when logic spans multiple repositories (projection, aggregation, a multi-step workflow). A *UseCase that 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.
  3. 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 test earns 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.
  4. 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.
  5. The View is dumb. A View does layout, if/switch on state, animation, and navigation — nothing else. No business logic, no formatting/number math, no try/catch, no data access. If a widget computes or fetches, it's in the wrong layer.
  6. 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.
  7. 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.
  8. 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.
  9. 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 Map never reaches a ViewModel or widget; unit math and locale formatting never happen in a widget.
  10. 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 injected Clock. A composition root wires the object graph once.
  11. 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.
  12. Errors are typed values at boundaries. Repositories/use-cases return a sealed Result/Failure rather than throwing across a layer; the UI switches exhaustively. See error-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: workspace with one root pubspec.lock; each package exposes one barrel (core.dart) over a private src/ — never import another package's src/; the pure-Dart core package declares no flutter dependency 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 class whose 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 inside build() or initState(). 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 count column next to the rows). It drifts. Derive it from the source of truth.
  • A ViewModel importing package:flutter/material.dart for BuildContext/widgets. ViewModels are UI-framework-light; passing BuildContext into the data layer is worse.
  • get_it/injectable/package:provider as a second DI container alongside Riverpod, or a global singleton / static mutable 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>/ or core/data location; grouping is feature-first.
  • The View is a dumb ConsumerWidget — no data access, no business logic, no formatting, no try/catch.
  • The ViewModel is a Notifier/AsyncNotifier/StreamNotifier exposing immutable state + intent methods; no public setters, no notifyListeners() 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/Map type 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.sh passes; dart analyze is clean.

Related skills

  • See state-management-riverpod for the Notifier/AsyncNotifier API, watch/read/listen split, family+autoDispose, and stale-closure hazards.
  • See widget-composition for the deep dive on building the dumb View (rule 5): const widget classes over _buildX() helpers, lean build(), and where the widget sits in this layer DAG.
  • See scaffold-feature-module for the step-by-step feature folder + typed go_router registration workflow.
  • See project-structure-and-packages for pubspec-as-audit-artifact, barrels, and one-way layering mechanics.
  • See app-startup-and-bootstrap for main() ordering, global error handlers, and DI overrides at the root.
  • See error-handling-typed-results for the sealed Result/Failure spine returned across boundaries.
  • See dart3-idioms-and-coding-standards for sealed types, exhaustive switches, and immutable value types.
  • See naming-conventions for the Screen/Notifier/Repository/Service/Failure role suffixes a grep can read.
  • See persistence-drift for the DAO/repository seam and transaction-per-mutation persistence.

References

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 calls notifyListeners() once — never inside build() 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 provider near the root. A MultiProvider at the app root creates services and repositories; a screen's ChangeNotifierProvider builds its ViewModel from context.read<XRepository>(). Depend on the abstract repository type so tests inject a fake.
  • Read vs listen: context.watch<T>() / Consumer to rebuild on change; context.read<T>() in callbacks. Never pass BuildContext down 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

scripts/

Keep looking

Skills are one crate of 326,614. Ordering is by how many stacks a row turns up in, so the top of any crate is what has actually been picked rather than what has the most stars.