FlutterGenCat
A CLI that scaffolds MVVM and Clean Architecture features for Flutter apps.
Most architecture generators make you choose everything up front. FlutterGenCat
starts you at plain MVVM and lets each feature earn its extra layers one flag at
a time — so a counter screen stays six files, while a checkout flow gets the
full domain layer.
Works with Provider, Riverpod and BLoC.
The name
Cats settle into whatever shape the moment calls for. FlutterGenCat generates Flutter architecture the same way — freely and flexibly, taking the form each feature actually needs rather than one imposed up front.
Requirements
| Minimum | |
|---|---|
| Dart SDK | 3.5.0 |
| Flutter | 3.24.0 |
Flutter 3.24.0 is the stable release that shipped Dart 3.5.0 — the two bounds
are the same requirement expressed twice. Only the Dart bound is declared in
pubspec.yaml; FlutterGenCat is a pure Dart CLI and does not depend on the
Flutter SDK to run.
Install
dart pub global activate flutter_gen_cat
Or, to pin it to a project:
# pubspec.yaml
dev_dependencies:
flutter_gen_cat: ^1.0.0
dart run flutter_gen_cat --help
Both flutter_gen_cat and the shorter fgc are installed.
Quick start
# Plain MVVM, Provider — six files, no ceremony (walked through in example/)
fgc create counter
# Riverpod, with a use case between the view model and the repository
fgc create cart -s riverpod --with-usecase
# The full Clean Architecture slice, on BLoC
fgc create checkout -s bloc --clean
# See what would happen, write nothing
fgc create orders --clean --dry-run
Generated files are written already formatted, at your project's own Dart
language version — the first dart format after generation is a no-op.
See example/ for every file create counter produces,
one layer at a time.
The progression
The base is always the same six layers. Each flag inserts one more indirection:
| adds | why you would | |
|---|---|---|
| (none) | model · repository (+impl) · view model · view · injector | The feature is a screen over an API. |
--with-entity |
entity, mapper | The wire format and the domain shape have started to diverge. |
--with-usecase |
use case | Business rules exist and do not belong in the view model. |
--with-datasource |
data source (+impl) | Transport is worth faking in tests, or there is more than one source. |
--clean |
all three | You already know this feature is complex. |
Flags compose freely — --with-usecase alone is a perfectly reasonable place to
sit for a long time.
Folder layout follows the layers
Plain MVVM gets a flat layout, because there is nothing to group yet:
lib/features/counter/
model/ counter_model.dart
repository/ counter_repository.dart # interface
counter_repository_impl.dart
view_model/ counter_view_model.dart
view/ counter_view.dart
di/ counter_injector.dart
Turn on any Clean Architecture layer and it switches to the layered one:
lib/features/checkout/
data/
model/ checkout_model.dart
mapper/ checkout_mapper.dart
datasource/ checkout_remote_data_source.dart
repository/ checkout_repository_impl.dart
domain/
entity/ checkout_entity.dart
repository/ checkout_repository.dart # interface
usecase/ get_checkout_use_case.dart
presentation/
view_model/ checkout_view_model.dart
view/ checkout_view.dart
di/ checkout_injector.dart
Override the choice with --layout flat or --layout layered whenever you
disagree.
The repository interface lives in domain/ and the implementation in data/.
That split is what keeps the dependency arrow pointing inwards: the presentation
layer depends on the contract, and only the injector knows which class satisfies
it.
Growing a feature
add generates one layer into a feature that already exists. It reads the
feature off disk first, so the new file matches the layout and the layers that
are already there:
# Detects that `cart` is layered and has an entity
fgc add usecase clear_cart --feature cart
# -> lib/features/cart/domain/usecase/clear_cart_use_case.dart
# Future<CartEntity> call() => _repository.fetch();
# Detects that `counter` is flat and has no entity
fgc add usecase reset_counter --feature counter
# -> lib/features/counter/usecase/reset_counter_use_case.dart
# Future<CounterModel> call() => _repository.fetch();
A second model, view or data source in the same feature is named after the artifact rather than the feature:
fgc add model cart_summary --feature cart
# -> lib/features/cart/model/cart_summary_model.dart (class CartSummaryModel)
Explicit flags override what was detected. --layout is the only way to move a
feature between layouts — adding --with-entity to a flat feature gives you an
entity-shaped file in the flat layout, rather than silently scattering the
feature across two conventions.
What you get per library
The data and domain layers are identical for all three. Only the presentation three differ:
| Provider | Riverpod | BLoC | |
|---|---|---|---|
| View model | ChangeNotifier |
AsyncNotifier |
Cubit<XState> |
| View | StatelessWidget + context.watch |
ConsumerWidget + ref.watch |
BlocBuilder |
| Async state | isLoading / error / data |
AsyncValue.when |
fields on the state class |
| Injector | XInjector.provide(child:) |
top-level providers | XInjector.provide(child:) |
Mounting a feature:
// Provider and BLoC
CounterInjector.provide(child: const CounterView())
// Riverpod — the providers are top-level; just wrap the app once
ProviderScope(child: MaterialApp(home: CounterView()))
No view model imports material.dart, so all three are unit testable without a
widget test.
Why Cubit rather than Bloc
An MVVM view model is a state holder, and an event class per method buys
nothing at scaffold time. Promote it to a full Bloc when the feature genuinely
needs an event log — the state class is already there.
Command reference
create <feature>
-s, --state-management provider (default) | riverpod | bloc
-o, --output Root features directory (default: lib/features)
--layout flat | layered
--with-entity Split a domain entity out of the model, plus its mapper
--with-usecase Put a use case between the view model and the repository
--with-datasource Make the repository delegate transport to a data source
--clean All three of the above
--usecase-name Name of the generated use case (default: get_<feature>)
--only Generate only these layers, comma separated
--dry-run Print the plan, write nothing
--overwrite Replace files that already exist
add <layer> <name>
Takes every option above, plus:
-f, --feature Feature to add to (default: <name>)
Layers: model, entity, mapper, datasource, repository,
repository-impl, usecase, viewmodel, view, injector.
Generated code is yours
Files are written once and never touched again. There is no .g.dart, no
build_runner step, no "do not edit" banner — create and add refuse to
overwrite an existing file unless you pass --overwrite.
Fill in the TODO(FlutterGenCat) markers and the scaffold stops being a
scaffold.
Using it as a library
The CLI is a thin shell over an API that never touches the disk, which is handy if you are building your own tooling:
import 'package:flutter_gen_cat/flutter_gen_cat.dart';
void main() {
final context = GenerationContext(
feature: 'checkout',
architecture: ArchitectureOptions.clean,
stateManagement: StateManagement.riverpod,
outputDir: 'lib/features', // the default
);
final files = FeatureScaffold(
context,
// Format for the target project rather than the newest language version
// the bundled formatter knows — this is what the CLI does.
languageVersion: resolveProjectLanguageVersion('.'),
).build();
for (final file in files) {
print('${file.path}\n${file.contents}');
}
}
GenerationContext derives its FeaturePaths from the feature name, the
output directory and the architecture's layout, so none of those has a second
place to be set — and therefore no way to disagree with itself.
Development
dart pub get
dart test
dart analyze
dart format .
License
See LICENSE.
Libraries
- flutter_gen_cat
- FlutterGenCat — a CLI scaffold tool for Flutter MVVM and Clean Architecture.